fix: 修复一些代码报错; 移除了好友复刻的功能

This commit is contained in:
cc
2026-01-14 19:44:09 +08:00
parent bd94ba7b1a
commit e7c93ea2f7
12 changed files with 27 additions and 1757 deletions

View File

@@ -1,404 +0,0 @@
.clone-page {
gap: 20px;
}
.clone-hero {
background: linear-gradient(135deg, rgba(39, 189, 149, 0.18), rgba(14, 116, 144, 0.14));
border: 1px solid rgba(47, 198, 156, 0.2);
}
.clone-hero-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.clone-hero-title {
display: flex;
gap: 14px;
align-items: center;
}
.clone-hero-title h2 {
margin: 0;
font-size: 18px;
}
.clone-hero-badges {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.clone-hero-badges span {
padding: 6px 12px;
border-radius: 9999px;
background: rgba(10, 70, 63, 0.18);
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
.clone-config {
display: flex;
flex-direction: column;
gap: 16px;
}
.clone-config-split {
display: grid;
grid-template-columns: minmax(240px, 340px) minmax(320px, 1fr);
gap: 20px;
align-items: stretch;
}
.clone-session-panel,
.clone-model-panel {
background: var(--bg-primary);
border-radius: 16px;
padding: 16px;
border: 1px solid var(--border-color);
}
.clone-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
color: var(--text-secondary);
font-size: 13px;
}
.clone-search {
display: flex;
align-items: center;
gap: 8px;
background: var(--bg-secondary);
padding: 6px 10px;
border-radius: 9999px;
border: 1px solid var(--border-color);
color: var(--text-tertiary);
}
.clone-search input {
border: none;
background: transparent;
color: var(--text-primary);
font-size: 12px;
width: 140px;
outline: none;
}
.clone-session-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow: auto;
padding-right: 4px;
}
.clone-session-item {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid transparent;
background: rgba(255, 255, 255, 0.02);
padding: 10px 12px;
border-radius: 14px;
text-align: left;
color: var(--text-primary);
cursor: pointer;
transition: all 0.2s;
}
.clone-session-item:hover {
border-color: rgba(45, 212, 191, 0.4);
background: rgba(45, 212, 191, 0.08);
}
.clone-session-item.active {
border-color: rgba(14, 116, 144, 0.6);
background: rgba(14, 116, 144, 0.16);
}
.clone-session-avatar {
width: 38px;
height: 38px;
border-radius: 12px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.2), rgba(14, 116, 144, 0.2));
color: var(--text-primary);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.clone-session-avatar img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.clone-session-info {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.clone-session-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clone-session-meta {
font-size: 12px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clone-model-tip {
margin-top: 10px;
font-size: 12px;
color: var(--text-tertiary);
}
.clone-label {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-tertiary);
}
.clone-label select,
.clone-label input {
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
color: var(--text-primary);
}
.clone-input-row {
display: flex;
gap: 12px;
align-items: center;
}
.clone-input-row input {
flex: 1;
}
.clone-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 20px;
}
.clone-options {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.clone-options label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
color: var(--text-tertiary);
}
.clone-options input[type='number'] {
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 8px 10px;
border-radius: 10px;
color: var(--text-primary);
}
.clone-checkbox {
flex-direction: row !important;
align-items: center;
gap: 8px;
margin-top: 20px;
color: var(--text-secondary);
}
.clone-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.clone-progress {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-tertiary);
}
.clone-alert {
margin-top: 12px;
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.3);
color: #b91c1c;
padding: 10px 12px;
border-radius: 12px;
font-size: 12px;
}
.clone-tone {
margin-top: 12px;
background: var(--bg-primary);
border-radius: 14px;
padding: 12px 14px;
border: 1px solid var(--border-color);
color: var(--text-primary);
font-size: 13px;
}
.clone-tone pre {
margin: 10px 0 0;
font-size: 12px;
color: var(--text-secondary);
white-space: pre-wrap;
}
.clone-query {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.clone-query input {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 10px 12px;
border-radius: 12px;
color: var(--text-primary);
}
.clone-query-results {
display: grid;
gap: 12px;
}
.clone-card {
background: var(--bg-primary);
border-radius: 14px;
padding: 12px 14px;
border: 1px solid var(--border-color);
}
.clone-card-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 6px;
}
.clone-card-content {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
}
.clone-empty {
padding: 14px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px dashed var(--border-color);
color: var(--text-tertiary);
font-size: 12px;
}
.clone-chat {
display: flex;
flex-direction: column;
gap: 12px;
}
.clone-chat-history {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 320px;
overflow: auto;
padding-right: 4px;
}
.clone-bubble {
max-width: 80%;
padding: 10px 12px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
}
.clone-bubble.user {
align-self: flex-end;
background: rgba(37, 99, 235, 0.12);
border: 1px solid rgba(37, 99, 235, 0.2);
}
.clone-bubble.assistant {
align-self: flex-start;
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.2);
}
.clone-chat-input {
display: flex;
gap: 12px;
align-items: center;
}
.clone-chat-input input {
flex: 1;
background: var(--bg-primary);
border: 1px solid var(--border-color);
padding: 10px 12px;
border-radius: 12px;
color: var(--text-primary);
}
@media (max-width: 960px) {
.clone-hero-content {
flex-direction: column;
align-items: flex-start;
}
.clone-input-row {
flex-direction: column;
align-items: stretch;
}
.clone-config-split {
grid-template-columns: 1fr;
}
.clone-search input {
width: 100%;
}
}

View File

@@ -1,481 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { Bot, Search, Wand2, Database, Play, RefreshCw, FileSearch } from 'lucide-react'
import type { ChatSession } from '../types/models'
import * as configService from '../services/config'
import './ClonePage.scss'
import './DataManagementPage.scss'
type ToneGuide = {
summary?: string
details?: Record<string, any>
}
type ChatEntry = {
role: 'user' | 'assistant'
content: string
}
function ClonePage() {
const [sessions, setSessions] = useState<ChatSession[]>([])
const [selectedSession, setSelectedSession] = useState('')
const [loadError, setLoadError] = useState<string | null>(null)
const [searchKeyword, setSearchKeyword] = useState('')
const [modelPath, setModelPath] = useState('')
const [modelSaving, setModelSaving] = useState(false)
const [resetIndex, setResetIndex] = useState(false)
const [batchSize, setBatchSize] = useState(200)
const [chunkGapMinutes, setChunkGapMinutes] = useState(10)
const [maxChunkChars, setMaxChunkChars] = useState(400)
const [maxChunkMessages, setMaxChunkMessages] = useState(20)
const [indexing, setIndexing] = useState(false)
const [indexStatus, setIndexStatus] = useState<{ totalMessages: number; totalChunks: number; hasMore: boolean } | null>(null)
const [toneGuide, setToneGuide] = useState<ToneGuide | null>(null)
const [toneLoading, setToneLoading] = useState(false)
const [toneSampleSize, setToneSampleSize] = useState(500)
const [toneError, setToneError] = useState<string | null>(null)
const [queryKeyword, setQueryKeyword] = useState('')
const [queryResults, setQueryResults] = useState<any[]>([])
const [queryLoading, setQueryLoading] = useState(false)
const [chatInput, setChatInput] = useState('')
const [chatHistory, setChatHistory] = useState<ChatEntry[]>([])
const [chatLoading, setChatLoading] = useState(false)
useEffect(() => {
const loadSessions = async () => {
try {
const result = await window.electronAPI.chat.getSessions()
if (!result.success || !result.sessions) {
setLoadError(result.error || '加载会话失败')
return
}
const privateSessions = result.sessions.filter((s) => !s.username.includes('@chatroom'))
setSessions(privateSessions)
if (privateSessions.length > 0) {
setSelectedSession((prev) => prev || privateSessions[0].username)
}
} catch (err) {
setLoadError(String(err))
}
}
loadSessions()
}, [])
useEffect(() => {
const loadModelPath = async () => {
const saved = await configService.getLlmModelPath()
if (saved) setModelPath(saved)
}
loadModelPath()
}, [])
useEffect(() => {
const removeListener = window.electronAPI.clone.onIndexProgress?.((payload) => {
setIndexStatus({
totalMessages: payload.totalMessages,
totalChunks: payload.totalChunks,
hasMore: payload.hasMore
})
})
return () => removeListener?.()
}, [])
const sessionLabelMap = useMemo(() => {
const map = new Map<string, string>()
for (const session of sessions) {
map.set(session.username, session.displayName || session.username)
}
return map
}, [sessions])
const filteredSessions = useMemo(() => {
const keyword = searchKeyword.trim().toLowerCase()
if (!keyword) return sessions
return sessions.filter((session) => {
const name = session.displayName || ''
return (
name.toLowerCase().includes(keyword) ||
session.username.toLowerCase().includes(keyword)
)
})
}, [sessions, searchKeyword])
const getAvatarLetter = (session: ChatSession) => {
const name = session.displayName || session.username
if (!name) return '?'
return [...name][0] || '?'
}
const handlePickModel = async () => {
const result = await window.electronAPI.dialog.openFile({
title: '选择本地 LLM 模型 (.gguf)',
filters: [{ name: 'GGUF', extensions: ['gguf'] }]
})
if (!result.canceled && result.filePaths.length > 0) {
setModelPath(result.filePaths[0])
}
}
const handleSaveModel = async () => {
setModelSaving(true)
try {
await configService.setLlmModelPath(modelPath)
} finally {
setModelSaving(false)
}
}
const handleIndex = async () => {
if (!selectedSession) return
setIndexing(true)
setIndexStatus(null)
try {
await window.electronAPI.clone.indexSession(selectedSession, {
reset: resetIndex,
batchSize,
chunkGapSeconds: Math.max(1, Math.round(chunkGapMinutes * 60)),
maxChunkChars,
maxChunkMessages
})
} catch (err) {
setLoadError(String(err))
} finally {
setIndexing(false)
}
}
const handleToneGuide = async () => {
if (!selectedSession) return
setToneLoading(true)
setToneError(null)
try {
const result = await window.electronAPI.clone.generateToneGuide(selectedSession, toneSampleSize)
if (result.success) {
setToneGuide(result.data || null)
} else {
setToneError(result.error || '生成失败')
}
} finally {
setToneLoading(false)
}
}
const handleLoadToneGuide = async () => {
if (!selectedSession) return
setToneLoading(true)
setToneError(null)
try {
const result = await window.electronAPI.clone.getToneGuide(selectedSession)
if (result.success) {
setToneGuide(result.data || null)
} else {
setToneError(result.error || '未找到说明书')
}
} finally {
setToneLoading(false)
}
}
const handleQuery = async () => {
if (!selectedSession || !queryKeyword.trim()) return
setQueryLoading(true)
try {
const result = await window.electronAPI.clone.query({
sessionId: selectedSession,
keyword: queryKeyword.trim(),
options: { topK: 5, roleFilter: 'target' }
})
if (result.success) {
setQueryResults(result.results || [])
} else {
setQueryResults([])
}
} finally {
setQueryLoading(false)
}
}
const handleChat = async () => {
if (!selectedSession || !chatInput.trim()) return
const message = chatInput.trim()
setChatInput('')
setChatHistory((prev) => [...prev, { role: 'user', content: message }])
setChatLoading(true)
try {
const result = await window.electronAPI.clone.chat({ sessionId: selectedSession, message })
const reply = result.success ? (result.response || '') : result.error || '生成失败'
setChatHistory((prev) => [...prev, { role: 'assistant', content: reply }])
} finally {
setChatLoading(false)
}
}
return (
<>
<div className="page-header">
<h1></h1>
</div>
<div className="page-scroll clone-page">
<section className="page-section clone-hero">
<div className="clone-hero-content">
<div className="clone-hero-title">
<Bot size={28} />
<div>
<h2></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="clone-hero-badges">
<span></span>
<span></span>
<span></span>
</div>
</div>
{loadError && <div className="clone-alert">{loadError}</div>}
</section>
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc"> LLM </p>
</div>
</div>
<div className="clone-config clone-config-split">
<div className="clone-session-panel">
<div className="clone-panel-header">
<span></span>
<div className="clone-search">
<Search size={14} />
<input
type="text"
placeholder="搜索好友"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="clone-session-list">
{filteredSessions.length === 0 ? (
<div className="clone-empty"></div>
) : (
filteredSessions.map((session) => (
<button
key={session.username}
className={`clone-session-item ${selectedSession === session.username ? 'active' : ''}`}
onClick={() => setSelectedSession(session.username)}
>
<div className="clone-session-avatar">
<span>{getAvatarLetter(session)}</span>
{session.avatarUrl && (
<img
src={session.avatarUrl}
alt={session.displayName || session.username}
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</div>
<div className="clone-session-info">
<div className="clone-session-name">{sessionLabelMap.get(session.username)}</div>
<div className="clone-session-meta">{session.username}</div>
</div>
</button>
))
)}
</div>
</div>
<div className="clone-model-panel">
<label className="clone-label">
LLM (.gguf)
<div className="clone-input-row">
<input
type="text"
value={modelPath}
onChange={(e) => setModelPath(e.target.value)}
placeholder="请选择本地模型路径"
/>
<button className="btn btn-secondary" onClick={handlePickModel}>
<FileSearch size={16} />
</button>
<button className="btn btn-primary" onClick={handleSaveModel} disabled={modelSaving}>
</button>
</div>
</label>
<div className="clone-model-tip">
使 1.5B GGUF
</div>
</div>
</div>
</section>
<div className="clone-grid">
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="clone-options">
<label>
<span></span>
<input type="number" min={50} max={1000} value={batchSize} onChange={(e) => setBatchSize(Number(e.target.value))} />
</label>
<label>
<span> ()</span>
<input type="number" min={1} max={60} value={chunkGapMinutes} onChange={(e) => setChunkGapMinutes(Number(e.target.value))} />
</label>
<label>
<span></span>
<input type="number" min={100} max={1200} value={maxChunkChars} onChange={(e) => setMaxChunkChars(Number(e.target.value))} />
</label>
<label>
<span></span>
<input type="number" min={5} max={50} value={maxChunkMessages} onChange={(e) => setMaxChunkMessages(Number(e.target.value))} />
</label>
<label className="clone-checkbox">
<input type="checkbox" checked={resetIndex} onChange={(e) => setResetIndex(e.target.checked)} />
</label>
</div>
<div className="clone-actions">
<button className="btn btn-primary" onClick={handleIndex} disabled={indexing || !selectedSession}>
{indexing ? <RefreshCw size={16} className="spin" /> : <Database size={16} />}
</button>
{indexStatus && (
<div className="clone-progress">
<span> {indexStatus.totalMessages}</span>
<span> {indexStatus.totalChunks}</span>
<span>{indexStatus.hasMore ? '索引中' : '已完成'}</span>
</div>
)}
</div>
</section>
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="clone-options">
<label>
<span></span>
<input type="number" min={100} max={2000} value={toneSampleSize} onChange={(e) => setToneSampleSize(Number(e.target.value))} />
</label>
<div className="clone-actions">
<button className="btn btn-primary" onClick={handleToneGuide} disabled={toneLoading || !selectedSession}>
{toneLoading ? <RefreshCw size={16} className="spin" /> : <Wand2 size={16} />}
</button>
<button className="btn btn-secondary" onClick={handleLoadToneGuide} disabled={toneLoading || !selectedSession}>
</button>
</div>
</div>
{toneError && <div className="clone-alert">{toneError}</div>}
{toneGuide && (
<div className="clone-tone">
<strong>{toneGuide.summary || '未生成摘要'}</strong>
{toneGuide.details && (
<pre>{JSON.stringify(toneGuide.details, null, 2)}</pre>
)}
</div>
)}
</section>
</div>
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="clone-query">
<input
type="text"
value={queryKeyword}
onChange={(e) => setQueryKeyword(e.target.value)}
placeholder="比如:上海、火锅、雨天"
/>
<button className="btn btn-secondary" onClick={handleQuery} disabled={queryLoading || !selectedSession}>
{queryLoading ? <RefreshCw size={16} className="spin" /> : <Search size={16} />}
</button>
</div>
<div className="clone-query-results">
{queryResults.length === 0 ? (
<div className="clone-empty"></div>
) : (
queryResults.map((item, idx) => (
<div key={`${item.id || idx}`} className="clone-card">
<div className="clone-card-meta">
<span>{item.role === 'target' ? '对方' : '我'}</span>
<span> {item.messageCount}</span>
</div>
<div className="clone-card-content">{item.content}</div>
</div>
))
)}
</div>
</section>
<section className="page-section">
<div className="section-header">
<div>
<h2></h2>
<p className="section-desc"></p>
</div>
</div>
<div className="clone-chat">
<div className="clone-chat-history">
{chatHistory.length === 0 ? (
<div className="clone-empty"></div>
) : (
chatHistory.map((entry, idx) => (
<div key={`${entry.role}-${idx}`} className={`clone-bubble ${entry.role}`}>
{entry.content}
</div>
))
)}
</div>
<div className="clone-chat-input">
<input
type="text"
value={chatInput}
onChange={(e) => setChatInput(e.target.value)}
placeholder="对分身说点什么..."
/>
<button className="btn btn-primary" onClick={handleChat} disabled={chatLoading || !selectedSession}>
{chatLoading ? <RefreshCw size={16} className="spin" /> : <Play size={16} />}
</button>
</div>
</div>
</section>
</div>
</>
)
}
export default ClonePage