diff --git a/src/app/chat/chat.css b/src/app/chat/chat.css
new file mode 100644
index 0000000..1975508
--- /dev/null
+++ b/src/app/chat/chat.css
@@ -0,0 +1,614 @@
+/* ============== Chat App Layout ============== */
+
+.chat-app {
+ display: flex;
+ height: 100vh;
+ width: 100vw;
+ overflow: hidden;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ color: #1a1a1a;
+ background: #fff;
+}
+
+/* ============== Sidebar ============== */
+
+.chat-sidebar {
+ width: 260px;
+ min-width: 260px;
+ background: #fafafa;
+ border-right: 1px solid #eee;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.sidebar-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 16px 16px 12px;
+ border-bottom: 1px solid #eee;
+}
+
+.sidebar-header h2 {
+ margin: 0;
+ font-size: 13px;
+ font-weight: 500;
+ color: #999;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.new-chat-btn {
+ all: unset;
+ cursor: pointer;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 6px;
+ color: #999;
+ font-size: 18px;
+ transition: color 0.15s, background 0.15s;
+}
+
+.new-chat-btn:hover {
+ color: #1a1a1a;
+ background: #eee;
+}
+
+.sidebar-tree {
+ flex: 1;
+ overflow-y: auto;
+ padding: 6px 0;
+}
+
+.sidebar-tree::-webkit-scrollbar {
+ width: 3px;
+}
+
+.sidebar-tree::-webkit-scrollbar-thumb {
+ background: #ddd;
+ border-radius: 2px;
+}
+
+.sidebar-footer {
+ padding: 10px 16px;
+ border-top: 1px solid #eee;
+ font-size: 11px;
+ color: #bbb;
+}
+
+/* Tree Nodes */
+
+.tree-node {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ padding: 3px 8px;
+ border-radius: 6px;
+ margin: 1px 6px;
+ transition: background 0.1s;
+}
+
+.tree-node:hover {
+ background: #f0f0f0;
+}
+
+.tree-node--active {
+ background: #e8e8e8 !important;
+}
+
+.tree-toggle {
+ all: unset;
+ cursor: pointer;
+ width: 14px;
+ font-size: 10px;
+ color: #bbb;
+ flex-shrink: 0;
+ text-align: center;
+}
+
+.tree-label {
+ all: unset;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex: 1;
+ min-width: 0;
+ font-size: 13px;
+ padding: 3px 0;
+ color: #444;
+}
+
+.tree-node--active .tree-label {
+ color: #1a1a1a;
+ font-weight: 500;
+}
+
+.tree-icon {
+ flex-shrink: 0;
+ font-size: 9px;
+ color: #bbb;
+}
+
+.tree-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tree-delete {
+ all: unset;
+ cursor: pointer;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ font-size: 14px;
+ color: #ccc;
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s;
+}
+
+.tree-node:hover .tree-delete {
+ opacity: 1;
+}
+
+.tree-delete:hover {
+ color: #e55;
+}
+
+/* ============== Main Chat Area ============== */
+
+.chat-main {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-width: 0;
+}
+
+.chat-feed-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ min-height: 0;
+}
+
+.chat-feed-empty {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #ccc;
+ font-size: 15px;
+}
+
+/* Breadcrumbs */
+
+.chat-breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 8px 24px;
+ border-bottom: 1px solid #f0f0f0;
+ font-size: 12px;
+ flex-wrap: wrap;
+ min-height: 34px;
+}
+
+.breadcrumb-sep {
+ color: #ddd;
+ font-size: 10px;
+}
+
+.breadcrumb-item {
+ all: unset;
+ cursor: pointer;
+ color: #aaa;
+ padding: 2px 6px;
+ border-radius: 3px;
+ transition: color 0.15s, background 0.15s;
+}
+
+.breadcrumb-item:hover {
+ color: #1a1a1a;
+ background: #f5f5f5;
+}
+
+.breadcrumb-item--active {
+ color: #1a1a1a;
+ font-weight: 500;
+}
+
+/* Chat Title */
+
+.chat-title-bar {
+ padding: 14px 24px 10px;
+ border-bottom: 1px solid #f0f0f0;
+}
+
+.chat-title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ cursor: default;
+ color: #1a1a1a;
+}
+
+.chat-title-input {
+ all: unset;
+ font-size: 18px;
+ font-weight: 600;
+ color: #1a1a1a;
+ border-bottom: 2px solid #1a1a1a;
+ padding-bottom: 2px;
+ width: 100%;
+}
+
+.chat-fork-badge {
+ font-size: 10px;
+ font-weight: 500;
+ background: #f0f0f0;
+ color: #888;
+ padding: 2px 8px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.chat-fork-info {
+ font-size: 12px;
+ color: #aaa;
+ margin-top: 4px;
+ display: block;
+}
+
+/* Chat Feed */
+
+.chat-feed {
+ flex: 1;
+ overflow-y: auto;
+ padding: 20px 24px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.chat-feed::-webkit-scrollbar {
+ width: 4px;
+}
+
+.chat-feed::-webkit-scrollbar-thumb {
+ background: #e0e0e0;
+ border-radius: 2px;
+}
+
+.chat-empty {
+ color: #ccc;
+ text-align: center;
+ margin-top: 60px;
+ font-size: 14px;
+}
+
+/* Messages */
+
+.chat-message {
+ padding: 10px 14px;
+ border-radius: 8px;
+ position: relative;
+}
+
+.chat-message--user {
+ background: #f8f8f8;
+}
+
+.chat-message--assistant {
+ background: #fff;
+}
+
+.chat-message--system {
+ background: #fafafa;
+ font-size: 13px;
+ border-left: 2px solid #e0e0e0;
+}
+
+.message-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 4px;
+ font-size: 12px;
+}
+
+.message-role {
+ font-weight: 600;
+ color: #888;
+}
+
+.message-time {
+ color: #ccc;
+}
+
+.fork-btn {
+ all: unset;
+ cursor: pointer;
+ margin-left: auto;
+ width: 22px;
+ height: 22px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ font-size: 14px;
+ color: #ccc;
+ opacity: 0;
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
+}
+
+.chat-message:hover .fork-btn {
+ opacity: 1;
+}
+
+.fork-btn:hover {
+ color: #1a1a1a;
+ background: #f0f0f0;
+}
+
+.message-content {
+ font-size: 14px;
+ line-height: 1.7;
+ white-space: pre-wrap;
+ word-break: break-word;
+ color: #333;
+}
+
+/* Internal Links (keyword-based) */
+
+.internal-link {
+ all: unset;
+ cursor: pointer;
+ color: #1a1a1a;
+ font-weight: 500;
+ text-decoration: none;
+ border-bottom: 1.5px solid #1a1a1a;
+ padding: 0 1px;
+ transition: background 0.15s;
+}
+
+.internal-link:hover {
+ background: #f0f0f0;
+}
+
+.internal-link--dead {
+ color: #bbb;
+ border-color: #ddd;
+ cursor: not-allowed;
+ text-decoration: line-through;
+}
+
+/* Selection Fork Popup */
+
+.selection-popup {
+ position: fixed;
+ z-index: 10000;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 6px 5px 10px;
+ background: #1a1a1a;
+ border-radius: 10px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18);
+ animation: popup-in 0.12s ease-out;
+ max-width: 380px;
+}
+
+@keyframes popup-in {
+ from {
+ opacity: 0;
+ transform: translateY(4px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.selection-popup-label {
+ color: #888;
+ font-size: 11px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 100px;
+ flex-shrink: 0;
+}
+
+.selection-popup-input {
+ all: unset;
+ flex: 1;
+ font-size: 13px;
+ color: #fff;
+ min-width: 140px;
+ padding: 4px 0;
+}
+
+.selection-popup-input::placeholder {
+ color: #666;
+}
+
+.selection-popup-send {
+ all: unset;
+ cursor: pointer;
+ width: 26px;
+ height: 26px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #333;
+ color: #fff;
+ border-radius: 6px;
+ font-size: 14px;
+ flex-shrink: 0;
+ transition: background 0.1s;
+}
+
+.selection-popup-send:hover {
+ background: #555;
+}
+
+/* Fork Dialog */
+
+.fork-dialog {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 8px;
+ padding: 8px;
+ background: #fafafa;
+ border-radius: 8px;
+ border: 1px solid #eee;
+}
+
+.fork-input {
+ all: unset;
+ flex: 1;
+ font-size: 13px;
+ padding: 6px 10px;
+ background: #fff;
+ border: 1px solid #e0e0e0;
+ border-radius: 6px;
+ color: #1a1a1a;
+}
+
+.fork-input::placeholder {
+ color: #bbb;
+}
+
+.fork-confirm {
+ all: unset;
+ cursor: pointer;
+ padding: 6px 14px;
+ background: #1a1a1a;
+ color: white;
+ border-radius: 6px;
+ font-size: 12px;
+ font-weight: 500;
+ transition: background 0.15s;
+}
+
+.fork-confirm:hover {
+ background: #333;
+}
+
+.fork-cancel {
+ all: unset;
+ cursor: pointer;
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: #bbb;
+ border-radius: 4px;
+}
+
+.fork-cancel:hover {
+ color: #1a1a1a;
+}
+
+/* Fork Indicators */
+
+.fork-indicator {
+ all: unset;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ margin-top: 6px;
+ margin-right: 6px;
+ padding: 3px 10px;
+ font-size: 11px;
+ color: #888;
+ background: #f5f5f5;
+ border: 1px solid #eee;
+ border-radius: 12px;
+ transition: background 0.15s, color 0.15s;
+}
+
+.fork-indicator:hover {
+ background: #e8e8e8;
+ color: #1a1a1a;
+}
+
+/* ============== Chat Input ============== */
+
+.chat-input-container {
+ display: flex;
+ gap: 8px;
+ padding: 12px 24px 16px;
+ border-top: 1px solid #f0f0f0;
+ background: #fff;
+ align-items: flex-end;
+}
+
+.chat-input {
+ all: unset;
+ flex: 1;
+ font-size: 14px;
+ line-height: 1.5;
+ padding: 10px 14px;
+ background: #fafafa;
+ border: 1px solid #e8e8e8;
+ border-radius: 10px;
+ color: #1a1a1a;
+ resize: none;
+ min-height: 20px;
+ max-height: 120px;
+ font-family: inherit;
+}
+
+.chat-input::placeholder {
+ color: #bbb;
+}
+
+.chat-input:focus {
+ border-color: #ccc;
+ outline: none;
+ background: #fff;
+}
+
+.chat-send-btn {
+ all: unset;
+ cursor: pointer;
+ padding: 10px 18px;
+ background: #1a1a1a;
+ color: white;
+ border-radius: 10px;
+ font-size: 13px;
+ font-weight: 500;
+ transition: background 0.15s;
+ white-space: nowrap;
+}
+
+.chat-send-btn:hover {
+ background: #333;
+}
+
+/* ============== Responsive ============== */
+
+@media (max-width: 768px) {
+ .chat-sidebar {
+ width: 200px;
+ min-width: 200px;
+ }
+}
diff --git a/src/app/chat/layout.tsx b/src/app/chat/layout.tsx
new file mode 100644
index 0000000..9469afe
--- /dev/null
+++ b/src/app/chat/layout.tsx
@@ -0,0 +1,21 @@
+import type { Metadata } from 'next'
+
+export const metadata: Metadata = {
+ title: 'Chat Links — Навигация по чатам',
+ description: 'Прототип чат-оболочки с внутренними гиперссылками',
+}
+
+export default function ChatLayout({ children }: { children: React.ReactNode }) {
+ return (
+ <>
+
+ {children}
+ >
+ )
+}
diff --git a/src/app/chat/page.tsx b/src/app/chat/page.tsx
new file mode 100644
index 0000000..5c9beae
--- /dev/null
+++ b/src/app/chat/page.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { ChatFeed } from '@/components/chat/ChatFeed'
+import { ChatInput } from '@/components/chat/ChatInput'
+import { ChatProvider } from '@/components/chat/ChatProvider'
+import { ChatSidebar } from '@/components/chat/ChatSidebar'
+import './chat.css'
+
+export default function ChatPage() {
+ return (
+
+
+
+ )
+}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index c5371a8..5af74b2 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,9 +1,6 @@
import type { Metadata } from 'next'
-import { Inter } from 'next/font/google'
import './globals.css'
-const inter = Inter({ subsets: ['latin'] })
-
const TITLE = 'draw fast • tldraw'
const DESCRIPTION = 'Draw a picture (fast) with tldraw'
const TWITTER_HANDLE = '@tldraw'
@@ -68,7 +65,7 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
-
{children}
+ {children}
)
}
diff --git a/src/components/chat/ChatFeed.tsx b/src/components/chat/ChatFeed.tsx
new file mode 100644
index 0000000..037ee0b
--- /dev/null
+++ b/src/components/chat/ChatFeed.tsx
@@ -0,0 +1,387 @@
+'use client'
+
+import { getAncestors, parseLinks, splitByKeywords } from '@/lib/chatStore'
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import { useChatContext } from './ChatProvider'
+
+function MessageContent({ content }: { content: string }) {
+ const { setCurrentChatId, store } = useChatContext()
+
+ // First pass: parse [[chatId|title]] links
+ const linkParts = parseLinks(content)
+
+ return (
+
+ {linkParts.map((part, i) => {
+ if (part.type === 'link' && part.chatId) {
+ const exists = !!store.chats[part.chatId]
+ return (
+
+ )
+ }
+ // Second pass: highlight keywords in plain text
+ const keywordParts = splitByKeywords(part.value, store.keywords)
+ return (
+
+ {keywordParts.map((kp, j) => {
+ if (kp.type === 'keyword' && kp.chatId) {
+ const exists = !!store.chats[kp.chatId]
+ return (
+
+ )
+ }
+ return {kp.value}
+ })}
+
+ )
+ })}
+
+ )
+}
+
+function SelectionPopup({
+ x,
+ y,
+ selectedText,
+ onSubmit,
+ onClose,
+}: {
+ x: number
+ y: number
+ selectedText: string
+ onSubmit: (prompt: string) => void
+ onClose: () => void
+}) {
+ const [prompt, setPrompt] = useState('')
+ const inputRef = useRef(null)
+
+ useEffect(() => {
+ // Focus after a tick so the selection doesn't get cleared
+ const t = setTimeout(() => inputRef.current?.focus(), 50)
+ return () => clearTimeout(t)
+ }, [])
+
+ return (
+ e.stopPropagation()}
+ >
+ {selectedText}
+ setPrompt(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && prompt.trim()) {
+ onSubmit(prompt.trim())
+ }
+ if (e.key === 'Escape') {
+ onClose()
+ }
+ }}
+ />
+
+
+ )
+}
+
+function ForkDialog({
+ onConfirm,
+ onCancel,
+}: {
+ onConfirm: (title: string) => void
+ onCancel: () => void
+}) {
+ const [title, setTitle] = useState('')
+ return (
+
+ setTitle(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && title.trim()) onConfirm(title.trim())
+ if (e.key === 'Escape') onCancel()
+ }}
+ autoFocus
+ />
+
+
+
+ )
+}
+
+export function ChatFeed() {
+ const {
+ store,
+ currentChat,
+ currentChatId,
+ forkChat,
+ forkFromSelection,
+ setCurrentChatId,
+ renameChat,
+ } = useChatContext()
+ const feedRef = useRef(null)
+ const [forkingIndex, setForkingIndex] = useState(null)
+ const [editingTitle, setEditingTitle] = useState(false)
+ const [titleValue, setTitleValue] = useState('')
+
+ // Selection popup state
+ const [selectionPopup, setSelectionPopup] = useState<{
+ x: number
+ y: number
+ text: string
+ messageIndex: number
+ } | null>(null)
+
+ useEffect(() => {
+ if (feedRef.current) {
+ feedRef.current.scrollTop = feedRef.current.scrollHeight
+ }
+ }, [currentChat?.messages.length])
+
+ // Detect text selection inside messages
+ const handleMouseUp = useCallback(() => {
+ const selection = window.getSelection()
+ if (!selection || selection.isCollapsed || !selection.toString().trim()) {
+ return
+ }
+
+ const text = selection.toString().trim()
+ if (text.length < 2 || text.length > 100) {
+ setSelectionPopup(null)
+ return
+ }
+
+ // Find which message the selection is in
+ const anchorNode = selection.anchorNode
+ if (!anchorNode) return
+
+ let messageEl: HTMLElement | null = null
+ let node: Node | null = anchorNode
+ while (node) {
+ if (node instanceof HTMLElement && node.classList.contains('chat-message')) {
+ messageEl = node
+ break
+ }
+ node = node.parentNode
+ }
+
+ if (!messageEl) return
+
+ const indexStr = messageEl.getAttribute('data-index')
+ if (indexStr === null) return
+
+ const range = selection.getRangeAt(0)
+ const rect = range.getBoundingClientRect()
+
+ setSelectionPopup({
+ x: rect.left + rect.width / 2 - 25,
+ y: rect.top - 40,
+ text,
+ messageIndex: parseInt(indexStr, 10),
+ })
+ }, [])
+
+ const handleMouseDown = useCallback(() => {
+ setSelectionPopup(null)
+ }, [])
+
+ useEffect(() => {
+ document.addEventListener('mousedown', handleMouseDown)
+ return () => document.removeEventListener('mousedown', handleMouseDown)
+ }, [handleMouseDown])
+
+ if (!currentChat) {
+ return Выберите чат
+ }
+
+ const ancestors = getAncestors(store, currentChatId)
+ const breadcrumbs = [...ancestors, currentChat]
+
+ return (
+
+ {/* Breadcrumb navigation */}
+
+ {breadcrumbs.map((chat, i) => (
+
+ {i > 0 && /}
+
+
+ ))}
+
+
+ {/* Chat title */}
+
+ {editingTitle ? (
+ setTitleValue(e.target.value)}
+ onBlur={() => {
+ if (titleValue.trim()) renameChat(currentChatId, titleValue.trim())
+ setEditingTitle(false)
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ if (titleValue.trim()) renameChat(currentChatId, titleValue.trim())
+ setEditingTitle(false)
+ }
+ }}
+ autoFocus
+ />
+ ) : (
+
{
+ setTitleValue(currentChat.title)
+ setEditingTitle(true)
+ }}
+ >
+ {currentChat.title}
+ {currentChat.parentId && (
+ ветка
+ )}
+
+ )}
+ {currentChat.parentId && currentChat.parentMessageIndex !== null && (
+
+ Форк от сообщения #{currentChat.parentMessageIndex + 1} в{' '}
+
+
+ )}
+
+
+ {/* Messages */}
+
+ {currentChat.messages.length === 0 && (
+
+ Начните разговор. Напишите сообщение ниже.
+
+ )}
+ {currentChat.messages.map((msg, index) => (
+
+
+
+ {msg.role === 'user'
+ ? 'Вы'
+ : msg.role === 'assistant'
+ ? 'Ассистент'
+ : 'Система'}
+
+
+ {new Date(msg.timestamp).toLocaleTimeString('ru-RU', {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ {msg.role !== 'system' && (
+
+ )}
+
+
+
+
+ {forkingIndex === index && (
+
{
+ forkChat(index, title)
+ setForkingIndex(null)
+ }}
+ onCancel={() => setForkingIndex(null)}
+ />
+ )}
+ {/* Show fork indicators */}
+ {Object.values(store.chats)
+ .filter(
+ (c) =>
+ c.parentId === currentChatId &&
+ c.parentMessageIndex === index
+ )
+ .map((forkedChat) => (
+
+ ))}
+
+ ))}
+
+
+ {/* Selection popup */}
+ {selectionPopup && (
+
{
+ forkFromSelection(selectionPopup.text, selectionPopup.messageIndex, prompt)
+ setSelectionPopup(null)
+ window.getSelection()?.removeAllRanges()
+ }}
+ onClose={() => {
+ setSelectionPopup(null)
+ window.getSelection()?.removeAllRanges()
+ }}
+ />
+ )}
+
+ )
+}
diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx
new file mode 100644
index 0000000..40c958a
--- /dev/null
+++ b/src/components/chat/ChatInput.tsx
@@ -0,0 +1,40 @@
+'use client'
+
+import React, { useState } from 'react'
+import { useChatContext } from './ChatProvider'
+
+export function ChatInput() {
+ const [text, setText] = useState('')
+ const { addMessage, simulateAssistant, currentChat } = useChatContext()
+
+ if (!currentChat) return null
+
+ const handleSend = () => {
+ const trimmed = text.trim()
+ if (!trimmed) return
+ addMessage('user', trimmed)
+ setText('')
+ simulateAssistant(trimmed)
+ }
+
+ return (
+
+
+ )
+}
diff --git a/src/components/chat/ChatProvider.tsx b/src/components/chat/ChatProvider.tsx
new file mode 100644
index 0000000..be3a20b
--- /dev/null
+++ b/src/components/chat/ChatProvider.tsx
@@ -0,0 +1,303 @@
+'use client'
+
+import {
+ Chat,
+ ChatMessage,
+ ChatStore,
+ createChat,
+ createInternalLink,
+ createMessage,
+ loadStore,
+ saveStore,
+} from '@/lib/chatStore'
+import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
+
+interface ChatContextType {
+ store: ChatStore
+ currentChatId: string
+ setCurrentChatId: (id: string) => void
+ currentChat: Chat | null
+ addMessage: (role: ChatMessage['role'], content: string) => void
+ forkChat: (messageIndex: number, title?: string) => string
+ forkFromSelection: (selectedText: string, messageIndex: number, prompt: string) => string
+ createNewChat: (title: string, parentId?: string | null) => string
+ deleteChat: (chatId: string) => void
+ renameChat: (chatId: string, title: string) => void
+ simulateAssistant: (userMessage: string) => void
+}
+
+const ChatContext = createContext(null)
+
+export function useChatContext() {
+ const ctx = useContext(ChatContext)
+ if (!ctx) throw new Error('useChatContext must be used within ChatProvider')
+ return ctx
+}
+
+export function ChatProvider({ children }: { children: React.ReactNode }) {
+ const [store, setStore] = useState(() => loadStore())
+ const [currentChatId, setCurrentChatId] = useState(store.rootChatId)
+ const storeRef = useRef(store)
+ storeRef.current = store
+
+ useEffect(() => {
+ saveStore(store)
+ }, [store])
+
+ const updateStore = useCallback((updater: (s: ChatStore) => ChatStore) => {
+ setStore((prev) => {
+ const next = updater(prev)
+ return next
+ })
+ }, [])
+
+ const currentChat = store.chats[currentChatId] || null
+
+ const addMessage = useCallback(
+ (role: ChatMessage['role'], content: string) => {
+ updateStore((s) => {
+ const chat = s.chats[currentChatId]
+ if (!chat) return s
+ return {
+ ...s,
+ chats: {
+ ...s.chats,
+ [currentChatId]: {
+ ...chat,
+ messages: [...chat.messages, createMessage(role, content)],
+ },
+ },
+ }
+ })
+ },
+ [currentChatId, updateStore]
+ )
+
+ const forkChat = useCallback(
+ (messageIndex: number, title?: string): string => {
+ const parentChat = storeRef.current.chats[currentChatId]
+ if (!parentChat) return currentChatId
+
+ const inheritedMessages = parentChat.messages.slice(0, messageIndex + 1)
+ const forkTitle = title || `Ветка: ${parentChat.title}`
+ const newChat = createChat(forkTitle, currentChatId, messageIndex, inheritedMessages)
+
+ // Add a system link message in the parent chat
+ const linkMsg = createMessage(
+ 'system',
+ `Создана ветка: ${createInternalLink(newChat.id, forkTitle)}`
+ )
+
+ updateStore((s) => ({
+ ...s,
+ chats: {
+ ...s.chats,
+ [newChat.id]: newChat,
+ [currentChatId]: {
+ ...s.chats[currentChatId],
+ messages: [...s.chats[currentChatId].messages, linkMsg],
+ },
+ },
+ }))
+
+ setCurrentChatId(newChat.id)
+ return newChat.id
+ },
+ [currentChatId, updateStore]
+ )
+
+ const forkFromSelection = useCallback(
+ (selectedText: string, messageIndex: number, prompt: string): string => {
+ const parentChat = storeRef.current.chats[currentChatId]
+ if (!parentChat) return currentChatId
+
+ const inheritedMessages = parentChat.messages.slice(0, messageIndex + 1)
+ const newChat = createChat(selectedText, currentChatId, messageIndex, inheritedMessages)
+
+ // Add the user's prompt as the first new message in the forked chat
+ const userMsg = createMessage('user', prompt)
+ newChat.messages.push(userMsg)
+
+ const linkMsg = createMessage(
+ 'system',
+ `Создана ветка: ${createInternalLink(newChat.id, selectedText)}`
+ )
+
+ const newChatId = newChat.id
+
+ updateStore((s) => ({
+ ...s,
+ chats: {
+ ...s.chats,
+ [newChatId]: newChat,
+ [currentChatId]: {
+ ...s.chats[currentChatId],
+ messages: [...s.chats[currentChatId].messages, linkMsg],
+ },
+ },
+ keywords: {
+ ...s.keywords,
+ [selectedText]: newChatId,
+ },
+ }))
+
+ setCurrentChatId(newChatId)
+
+ // Simulate assistant response in the new chat
+ setTimeout(() => {
+ const response = `Вы спросили про "${selectedText}": "${prompt}"\n\nДавайте разберём это подробнее. Этот чат унаследовал контекст из родительского разговора, так что мы можем продолжить с того места, где остановились.`
+ setStore((prev) => {
+ const c = prev.chats[newChatId]
+ if (!c) return prev
+ return {
+ ...prev,
+ chats: {
+ ...prev.chats,
+ [newChatId]: {
+ ...c,
+ messages: [...c.messages, createMessage('assistant', response)],
+ },
+ },
+ }
+ })
+ }, 500)
+
+ return newChatId
+ },
+ [currentChatId, updateStore]
+ )
+
+ const createNewChat = useCallback(
+ (title: string, parentId: string | null = null): string => {
+ const newChat = createChat(title, parentId)
+
+ // If parent, add link in parent
+ if (parentId && storeRef.current.chats[parentId]) {
+ const linkMsg = createMessage(
+ 'system',
+ `Новый чат: ${createInternalLink(newChat.id, title)}`
+ )
+ updateStore((s) => ({
+ ...s,
+ chats: {
+ ...s.chats,
+ [newChat.id]: newChat,
+ [parentId]: {
+ ...s.chats[parentId],
+ messages: [...s.chats[parentId].messages, linkMsg],
+ },
+ },
+ }))
+ } else {
+ updateStore((s) => ({
+ ...s,
+ chats: { ...s.chats, [newChat.id]: newChat },
+ }))
+ }
+
+ setCurrentChatId(newChat.id)
+ return newChat.id
+ },
+ [updateStore]
+ )
+
+ const deleteChat = useCallback(
+ (chatId: string) => {
+ if (chatId === store.rootChatId) return
+ updateStore((s) => {
+ const newChats = { ...s.chats }
+ // Delete chat and all descendants
+ const toDelete = [chatId]
+ while (toDelete.length > 0) {
+ const id = toDelete.pop()!
+ delete newChats[id]
+ Object.values(s.chats)
+ .filter((c) => c.parentId === id)
+ .forEach((c) => toDelete.push(c.id))
+ }
+ return { ...s, chats: newChats }
+ })
+ if (currentChatId === chatId) {
+ setCurrentChatId(store.rootChatId)
+ }
+ },
+ [currentChatId, store.rootChatId, updateStore]
+ )
+
+ const renameChat = useCallback(
+ (chatId: string, title: string) => {
+ updateStore((s) => ({
+ ...s,
+ chats: {
+ ...s.chats,
+ [chatId]: { ...s.chats[chatId], title },
+ },
+ }))
+ },
+ [updateStore]
+ )
+
+ const simulateAssistant = useCallback(
+ (userMessage: string) => {
+ const chat = storeRef.current.chats[currentChatId]
+ if (!chat) return
+
+ const isForked = chat.parentId !== null
+ const msgCount = chat.messages.length
+
+ let response: string
+ if (msgCount <= 1) {
+ response = isForked
+ ? `Это ветка от "${storeRef.current.chats[chat.parentId!]?.title || 'родительского чата'}". Контекст унаследован. Продолжим обсуждение "${chat.title}".\n\nО чём именно хотите поговорить в этой ветке?`
+ : `Привет! Я готов обсуждать "${userMessage}". Вы можете в любой момент создать ветку разговора, нажав на кнопку форка рядом с любым сообщением, или вставить ссылку на другой чат.`
+ } else {
+ const tips = [
+ 'Кстати, вы можете форкнуть этот разговор в любом месте, чтобы исследовать альтернативную тему.',
+ 'Если хотите углубиться в эту тему — создайте ветку от этого сообщения.',
+ 'Интересная мысль! Можно создать отдельную ветку для детального обсуждения.',
+ 'Продолжаем. Помните, что все ветки доступны в дереве навигации слева.',
+ ]
+ response = `Вы написали: "${userMessage}"\n\n${tips[msgCount % tips.length]}`
+ }
+
+ // Delayed response to simulate thinking
+ setTimeout(() => {
+ setStore((prev) => {
+ const c = prev.chats[currentChatId]
+ if (!c) return prev
+ return {
+ ...prev,
+ chats: {
+ ...prev.chats,
+ [currentChatId]: {
+ ...c,
+ messages: [...c.messages, createMessage('assistant', response)],
+ },
+ },
+ }
+ })
+ }, 500)
+ },
+ [currentChatId]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/src/components/chat/ChatSidebar.tsx b/src/components/chat/ChatSidebar.tsx
new file mode 100644
index 0000000..808bbcb
--- /dev/null
+++ b/src/components/chat/ChatSidebar.tsx
@@ -0,0 +1,84 @@
+'use client'
+
+import { Chat, getChildren } from '@/lib/chatStore'
+import React, { useState } from 'react'
+import { useChatContext } from './ChatProvider'
+
+function TreeNode({ chat, depth = 0 }: { chat: Chat; depth?: number }) {
+ const { store, currentChatId, setCurrentChatId, deleteChat } = useChatContext()
+ const children = getChildren(store, chat.id)
+ const [expanded, setExpanded] = useState(true)
+ const isActive = currentChatId === chat.id
+ const hasChildren = children.length > 0
+
+ return (
+
+
+
+
+ {chat.parentId && (
+
+ )}
+
+ {expanded &&
+ children
+ .sort((a, b) => a.createdAt - b.createdAt)
+ .map((child) => (
+
+ ))}
+
+ )
+}
+
+export function ChatSidebar() {
+ const { store, createNewChat, currentChatId } = useChatContext()
+ const rootChat = store.chats[store.rootChatId]
+
+ return (
+
+
+
Чаты
+
+
+
+ {rootChat && }
+
+
+
+ {Object.keys(store.chats).length} чатов
+
+
+
+ )
+}
diff --git a/src/lib/chatStore.ts b/src/lib/chatStore.ts
new file mode 100644
index 0000000..a31924c
--- /dev/null
+++ b/src/lib/chatStore.ts
@@ -0,0 +1,161 @@
+export interface ChatMessage {
+ id: string
+ role: 'user' | 'assistant' | 'system'
+ content: string
+ timestamp: number
+}
+
+export interface Chat {
+ id: string
+ title: string
+ parentId: string | null
+ parentMessageIndex: number | null // fork point in parent chat
+ messages: ChatMessage[]
+ createdAt: number
+}
+
+export interface ChatStore {
+ chats: Record
+ rootChatId: string
+ // keyword → chatId: selected text becomes a link everywhere
+ keywords: Record
+}
+
+const STORAGE_KEY = 'chat-links-store'
+
+function generateId(): string {
+ return Math.random().toString(36).substring(2, 10) + Date.now().toString(36)
+}
+
+export function createMessage(
+ role: ChatMessage['role'],
+ content: string
+): ChatMessage {
+ return {
+ id: generateId(),
+ role,
+ content,
+ timestamp: Date.now(),
+ }
+}
+
+export function createChat(
+ title: string,
+ parentId: string | null = null,
+ parentMessageIndex: number | null = null,
+ inheritedMessages: ChatMessage[] = []
+): Chat {
+ return {
+ id: generateId(),
+ title,
+ parentId,
+ parentMessageIndex,
+ messages: inheritedMessages.map((m) => ({ ...m, id: generateId() })),
+ createdAt: Date.now(),
+ }
+}
+
+export function loadStore(): ChatStore {
+ if (typeof window === 'undefined') {
+ const rootChat = createChat('Главный чат')
+ return { chats: { [rootChat.id]: rootChat }, rootChatId: rootChat.id, keywords: {} }
+ }
+ try {
+ const data = localStorage.getItem(STORAGE_KEY)
+ if (data) {
+ const parsed = JSON.parse(data)
+ if (!parsed.keywords) parsed.keywords = {}
+ return parsed
+ }
+ } catch {
+ // ignore
+ }
+ const rootChat = createChat('Главный чат')
+ return { chats: { [rootChat.id]: rootChat }, rootChatId: rootChat.id, keywords: {} }
+}
+
+export function saveStore(store: ChatStore): void {
+ if (typeof window === 'undefined') return
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
+}
+
+export function getChildren(
+ store: ChatStore,
+ chatId: string
+): Chat[] {
+ return Object.values(store.chats).filter((c) => c.parentId === chatId)
+}
+
+export function getAncestors(store: ChatStore, chatId: string): Chat[] {
+ const ancestors: Chat[] = []
+ let current = store.chats[chatId]
+ while (current?.parentId) {
+ current = store.chats[current.parentId]
+ if (current) ancestors.unshift(current)
+ }
+ return ancestors
+}
+
+// Parse internal links: [[chatId|display text]]
+export function parseLinks(
+ content: string
+): Array<{ type: 'text' | 'link'; value: string; chatId?: string }> {
+ const parts: Array<{ type: 'text' | 'link'; value: string; chatId?: string }> = []
+ const regex = /\[\[([^\]|]+)\|([^\]]+)\]\]/g
+ let lastIndex = 0
+ let match
+
+ while ((match = regex.exec(content)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push({ type: 'text', value: content.slice(lastIndex, match.index) })
+ }
+ parts.push({ type: 'link', value: match[2], chatId: match[1] })
+ lastIndex = regex.lastIndex
+ }
+
+ if (lastIndex < content.length) {
+ parts.push({ type: 'text', value: content.slice(lastIndex) })
+ }
+
+ return parts
+}
+
+export function createInternalLink(chatId: string, title: string): string {
+ return `[[${chatId}|${title}]]`
+}
+
+// Split text by keywords, returning segments with keyword matches
+export function splitByKeywords(
+ text: string,
+ keywords: Record
+): Array<{ type: 'text' | 'keyword'; value: string; chatId?: string }> {
+ const entries = Object.entries(keywords)
+ if (entries.length === 0) return [{ type: 'text', value: text }]
+
+ // Sort by length descending so longer keywords match first
+ entries.sort((a, b) => b[0].length - a[0].length)
+
+ const escaped = entries.map(([kw]) => kw.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
+ const pattern = new RegExp(`(${escaped.join('|')})`, 'gi')
+
+ const parts: Array<{ type: 'text' | 'keyword'; value: string; chatId?: string }> = []
+ let lastIndex = 0
+ let match
+
+ while ((match = pattern.exec(text)) !== null) {
+ if (match.index > lastIndex) {
+ parts.push({ type: 'text', value: text.slice(lastIndex, match.index) })
+ }
+ const matched = match[1]
+ // Find the keyword (case-insensitive lookup)
+ const entry = entries.find(([kw]) => kw.toLowerCase() === matched.toLowerCase())
+ parts.push({ type: 'keyword', value: matched, chatId: entry?.[1] })
+ lastIndex = pattern.lastIndex
+ }
+
+ if (lastIndex < text.length) {
+ parts.push({ type: 'text', value: text.slice(lastIndex) })
+ }
+
+ return parts
+}