Skip to content
Closed
107 changes: 107 additions & 0 deletions src/hooks/use-conversation-title-config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import Browser from 'webextension-polyfill'
import { canonicalizeApiMode } from '../config/model-key-migrations.mjs'

const STORAGE_KEYS = ['autoGenerateConversationTitle', 'conversationTitleApiMode']

export const defaultConversationTitleConfig = {
autoGenerateConversationTitle: false,
conversationTitleApiMode: null,
}

function normalizeTitleApiMode(value) {
const canonical = canonicalizeApiMode(value)
if (!canonical || typeof canonical !== 'object') return null
return { ...canonical, apiKey: '' }
}

function normalizeConversationTitleConfig(value) {
return {
autoGenerateConversationTitle: value?.autoGenerateConversationTitle === true,
conversationTitleApiMode: normalizeTitleApiMode(value?.conversationTitleApiMode),
}
}

export async function getConversationTitleConfig() {
const stored = await Browser.storage.local.get(STORAGE_KEYS)
const normalized = normalizeConversationTitleConfig(stored)
if (
stored.conversationTitleApiMode &&
JSON.stringify(stored.conversationTitleApiMode) !==
JSON.stringify(normalized.conversationTitleApiMode)
) {
await Browser.storage.local.set({
conversationTitleApiMode: normalized.conversationTitleApiMode,
})
}
return normalized
}

export async function setConversationTitleConfig(changes) {
const requestedChanges = changes && typeof changes === 'object' ? changes : {}
const normalized = {}
if (Object.hasOwn(requestedChanges, 'autoGenerateConversationTitle')) {
normalized.autoGenerateConversationTitle =
requestedChanges.autoGenerateConversationTitle === true
}
if (Object.hasOwn(requestedChanges, 'conversationTitleApiMode')) {
normalized.conversationTitleApiMode = normalizeTitleApiMode(
requestedChanges.conversationTitleApiMode,
)
}
if (Object.keys(normalized).length > 0) await Browser.storage.local.set(normalized)
return normalized
}

export function useConversationTitleConfig() {
const [config, setConfig] = useState(defaultConversationTitleConfig)

useEffect(() => {
let active = true
getConversationTitleConfig()
.then((loadedConfig) => {
if (active) setConfig(loadedConfig)
})
.catch((error) => {
console.warn('[conversation-title] Failed to load title settings:', error)
})

const listener = (changes, areaName) => {
if (areaName && areaName !== 'local') return
const update = {}
if (Object.hasOwn(changes, 'autoGenerateConversationTitle')) {
update.autoGenerateConversationTitle =
changes.autoGenerateConversationTitle.newValue === true
}
if (Object.hasOwn(changes, 'conversationTitleApiMode')) {
update.conversationTitleApiMode = normalizeTitleApiMode(
changes.conversationTitleApiMode.newValue,
)
}
if (Object.keys(update).length > 0) {
setConfig((current) => ({ ...current, ...update }))
}
}

Browser.storage.onChanged.addListener(listener)
return () => {
active = false
Browser.storage.onChanged.removeListener(listener)
}
}, [])

const updateConfig = async (changes) => {
try {
const normalized = await setConversationTitleConfig(changes)
if (Object.keys(normalized).length > 0) {
setConfig((current) => ({ ...current, ...normalized }))
}
return true
} catch (error) {
console.warn('[conversation-title] Failed to save title settings:', error)
return false
}
}

return [config, updateConfig]
}
129 changes: 114 additions & 15 deletions src/pages/IndependentPanel/App.jsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import {
claimSessionTitleGeneration,
completeSessionTitleGeneration,
createSession,
resetSessions,
deleteSession,
failSessionTitleGeneration,
getSession,
getSessions,
isSessionTitleGenerationStale,
resetSessions,
updateSession,
getSession,
deleteSession,
} from '../../services/local-session.mjs'
import { useEffect, useRef, useState } from 'react'
import {
generateConversationTitle,
getSessionDisplayName,
} from '../../services/session-title.mjs'
import { isConversationTitleModelAvailable } from '../../services/conversation-title-model.mjs'
import { useCallback, useEffect, useRef, useState } from 'react'
import './styles.scss'
import { useConfig } from '../../hooks/use-config.mjs'
import { useConversationTitleConfig } from '../../hooks/use-conversation-title-config.mjs'
import { useTranslation } from 'react-i18next'
import ConfirmButton from '../../components/ConfirmButton'
import ConversationCard from '../../components/ConversationCard'
Expand All @@ -21,11 +31,13 @@ function App() {
const { t } = useTranslation()
const [collapsed, setCollapsed] = useState(true)
const config = useConfig(null, false)
const [conversationTitleConfig] = useConversationTitleConfig()
const [sessions, setSessions] = useState([])
const [sessionId, setSessionId] = useState(null)
const [currentSession, setCurrentSession] = useState(null)
const [renderContent, setRenderContent] = useState(false)
const currentPort = useRef(null)
const titleGenerationInFlightRef = useRef(new Set())

const setSessionIdSafe = async (sessionId) => {
if (currentPort.current) {
Expand All @@ -42,6 +54,55 @@ function App() {
else if (currentSessions.length > 0) setSessionId(currentSessions[0].sessionId)
}

const generateTitleIfNeeded = useCallback(
async (session) => {
const titleRuntimeConfig = { ...config, ...conversationTitleConfig }
if (
!conversationTitleConfig.autoGenerateConversationTitle ||
!isConversationTitleModelAvailable(titleRuntimeConfig)
) {
return
}
if (!session?.sessionId || titleGenerationInFlightRef.current.has(session.sessionId)) return
if (!Array.isArray(session.conversationRecords) || session.conversationRecords.length !== 1) {
return
}

const firstRecord = session.conversationRecords[0]
if (!String(firstRecord?.question || '').trim() || !String(firstRecord?.answer || '').trim()) {
return
}

titleGenerationInFlightRef.current.add(session.sessionId)
let generationId
try {
const claim = await claimSessionTitleGeneration(session.sessionId)
if (!claim.claimed) return
setSessions([...claim.currentSessions])

generationId = claim.session.sessionTitleGenerationId
const title = await generateConversationTitle({
config: titleRuntimeConfig,
question: firstRecord.question,
answer: firstRecord.answer,
})
const completed = await completeSessionTitleGeneration(
session.sessionId,
title,
generationId,
)
setSessions([...completed.currentSessions])
} catch (error) {
console.warn('[conversation-title] Failed to generate a conversation title:', error)
const failed = await failSessionTitleGeneration(session.sessionId, generationId)
setSessions([...failed.currentSessions])
} finally {
titleGenerationInFlightRef.current.delete(session.sessionId)
}
},
[config, conversationTitleConfig],
)

useEffect(() => {
document.documentElement.dataset.theme = config.themeMode
}, [config.themeMode])
Expand Down Expand Up @@ -81,6 +142,31 @@ function App() {
})()
}, [sessionId])

useEffect(() => {
const selectedSession = sessions.find((session) => session.sessionId === sessionId)
if (selectedSession) setCurrentSession(selectedSession)
}, [sessions, sessionId])

useEffect(() => {
const titleRuntimeConfig = { ...config, ...conversationTitleConfig }
if (
!conversationTitleConfig.autoGenerateConversationTitle ||
!isConversationTitleModelAvailable(titleRuntimeConfig)
) {
return
}

const selectedSession = sessions.find((session) => session.sessionId === sessionId)
const canStartOrRecover =
selectedSession &&
Array.isArray(selectedSession.conversationRecords) &&
selectedSession.conversationRecords.length === 1 &&
(selectedSession.sessionTitleGenerationStatus === undefined ||
selectedSession.sessionTitleGenerationStatus === 'idle' ||
isSessionTitleGenerationStale(selectedSession))
if (canStartOrRecover) void generateTitleIfNeeded(selectedSession)
}, [config, conversationTitleConfig, generateTitleIfNeeded, sessionId, sessions])

const toggleSidebar = () => {
setCollapsed(!collapsed)
}
Expand Down Expand Up @@ -120,20 +206,27 @@ function App() {
</div>
<hr />
<div className="chat-list">
{sessions.map(
(
session,
index, // TODO editable session name
) => (
{sessions.map((session) => {
const displayName = getSessionDisplayName(session, t('New Chat'))
return (
<button
key={index}
key={session.sessionId}
className={`normal-button ${sessionId === session.sessionId ? 'active' : ''}`}
style="display: flex; align-items: center; justify-content: space-between;"
title={displayName}
onClick={() => {
setSessionIdSafe(session.sessionId)
}}
>
{session.sessionName}
<span
style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{displayName}
</span>
<span className="gpt-util-group">
<DeleteButton
size={14}
Expand All @@ -147,8 +240,8 @@ function App() {
/>
</span>
</button>
),
)}
)
})}
</div>
<hr />
<div className="chat-sidebar-button-group">
Expand All @@ -173,8 +266,14 @@ function App() {
onUpdate={(port, session, cData) => {
currentPort.current = port
if (cData.length > 0 && cData[cData.length - 1].done) {
updateSession(session).then(setSessions)
setCurrentSession(session)
void (async () => {
const updatedSessions = await updateSession(session)
const savedSession =
updatedSessions.find((item) => item.sessionId === session.sessionId) || session
setSessions(updatedSessions)
setCurrentSession(savedSession)
await generateTitleIfNeeded(savedSession)
})()
}
}}
/>
Expand Down
Loading