mirror of
https://github.com/CJackHwang/ds2api.git
synced 2026-05-23 10:57:44 +08:00
feat(webui): merge parsed HISTORY into chat history list
This commit is contained in:
@@ -8,6 +8,12 @@ const LIMIT_OPTIONS = [0, 10, 20, 50]
|
|||||||
const DISABLED_LIMIT = 0
|
const DISABLED_LIMIT = 0
|
||||||
const MESSAGE_COLLAPSE_AT = 700
|
const MESSAGE_COLLAPSE_AT = 700
|
||||||
const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode'
|
const VIEW_MODE_KEY = 'ds2api_chat_history_view_mode'
|
||||||
|
const HISTORY_WRAPPER_PREFIX = '[file content end]\n\n'
|
||||||
|
const HISTORY_WRAPPER_SUFFIX = '\n[file name]: IGNORE\n[file content begin]'
|
||||||
|
const BEGIN_SENTENCE_MARKER = '<|begin▁of▁sentence|>'
|
||||||
|
const USER_MARKER = '<|User|>'
|
||||||
|
const ASSISTANT_MARKER = '<|Assistant|>'
|
||||||
|
const END_SENTENCE_MARKER = '<|end▁of▁sentence|>'
|
||||||
|
|
||||||
function formatDateTime(value, lang) {
|
function formatDateTime(value, lang) {
|
||||||
if (!value) return '-'
|
if (!value) return '-'
|
||||||
@@ -105,14 +111,137 @@ function MergeModeIcon() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequestMessages({ item, t }) {
|
function unwrapHistoryTranscript(historyText) {
|
||||||
const messages = Array.isArray(item?.messages) && item.messages.length > 0
|
const trimmed = String(historyText || '').trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
if (trimmed.startsWith(HISTORY_WRAPPER_PREFIX)) {
|
||||||
|
if (!trimmed.endsWith(HISTORY_WRAPPER_SUFFIX)) return null
|
||||||
|
return trimmed.slice(HISTORY_WRAPPER_PREFIX.length, trimmed.length - HISTORY_WRAPPER_SUFFIX.length).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
trimmed.includes('[file content end]')
|
||||||
|
|| trimmed.includes('[file name]: IGNORE')
|
||||||
|
|| trimmed.includes('[file content begin]')
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStrictHistoryMessages(historyText) {
|
||||||
|
const transcript = unwrapHistoryTranscript(historyText)
|
||||||
|
if (!transcript || !transcript.startsWith(BEGIN_SENTENCE_MARKER)) return null
|
||||||
|
|
||||||
|
let cursor = BEGIN_SENTENCE_MARKER.length
|
||||||
|
const parsed = []
|
||||||
|
let expectedRole = null
|
||||||
|
let trailingAssistantPromptOnly = false
|
||||||
|
|
||||||
|
while (cursor < transcript.length) {
|
||||||
|
if (expectedRole === null) {
|
||||||
|
if (transcript.startsWith(USER_MARKER, cursor)) {
|
||||||
|
expectedRole = 'user'
|
||||||
|
} else if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
|
||||||
|
expectedRole = 'assistant'
|
||||||
|
} else if (transcript.slice(cursor).trim() === '') {
|
||||||
|
break
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transcript.startsWith(USER_MARKER, cursor)) {
|
||||||
|
if (expectedRole !== 'user') return null
|
||||||
|
cursor += USER_MARKER.length
|
||||||
|
const nextAssistant = transcript.indexOf(ASSISTANT_MARKER, cursor)
|
||||||
|
const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor)
|
||||||
|
if (nextAssistant < 0) return null
|
||||||
|
if (nextSentenceEnd >= 0 && nextSentenceEnd < nextAssistant) {
|
||||||
|
const assistantStart = nextSentenceEnd + END_SENTENCE_MARKER.length
|
||||||
|
if (!transcript.startsWith(ASSISTANT_MARKER, assistantStart)) return null
|
||||||
|
parsed.push({
|
||||||
|
role: 'user',
|
||||||
|
content: transcript.slice(cursor, nextSentenceEnd),
|
||||||
|
})
|
||||||
|
cursor = assistantStart
|
||||||
|
expectedRole = 'assistant'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
parsed.push({
|
||||||
|
role: 'user',
|
||||||
|
content: transcript.slice(cursor, nextAssistant),
|
||||||
|
})
|
||||||
|
const assistantStart = nextAssistant + ASSISTANT_MARKER.length
|
||||||
|
if (transcript.slice(assistantStart).trim() === '') {
|
||||||
|
trailingAssistantPromptOnly = true
|
||||||
|
cursor = assistantStart
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = nextAssistant
|
||||||
|
expectedRole = 'assistant'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transcript.startsWith(ASSISTANT_MARKER, cursor)) {
|
||||||
|
if (expectedRole !== 'assistant') return null
|
||||||
|
cursor += ASSISTANT_MARKER.length
|
||||||
|
const nextSentenceEnd = transcript.indexOf(END_SENTENCE_MARKER, cursor)
|
||||||
|
if (nextSentenceEnd < 0) return null
|
||||||
|
parsed.push({
|
||||||
|
role: 'assistant',
|
||||||
|
content: transcript.slice(cursor, nextSentenceEnd),
|
||||||
|
})
|
||||||
|
cursor = nextSentenceEnd + END_SENTENCE_MARKER.length
|
||||||
|
expectedRole = 'user'
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transcript.slice(cursor).trim() === '') break
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trailingAssistantPromptOnly && parsed[parsed.length - 1]?.role !== 'assistant') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildListModeMessages(item, t) {
|
||||||
|
const liveMessages = Array.isArray(item?.messages) && item.messages.length > 0
|
||||||
? item.messages
|
? item.messages
|
||||||
: [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }]
|
: [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }]
|
||||||
|
const historyMessages = parseStrictHistoryMessages(item?.history_text)
|
||||||
|
|
||||||
|
if (!historyMessages?.length) {
|
||||||
|
return { messages: liveMessages, historyMerged: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertAt = liveMessages.findIndex(message => {
|
||||||
|
const role = String(message?.role || '').trim().toLowerCase()
|
||||||
|
return role !== 'system' && role !== 'developer'
|
||||||
|
})
|
||||||
|
const mergedMessages = [...liveMessages]
|
||||||
|
mergedMessages.splice(insertAt < 0 ? mergedMessages.length : insertAt, 0, ...historyMessages)
|
||||||
|
|
||||||
|
return { messages: mergedMessages, historyMerged: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
function RequestMessages({ item, t, messages }) {
|
||||||
|
const requestMessages = Array.isArray(messages) && messages.length > 0
|
||||||
|
? messages
|
||||||
|
: [{ role: 'user', content: item?.user_input || t('chatHistory.emptyUserInput') }]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 max-w-4xl mx-auto">
|
<div className="space-y-5 max-w-4xl mx-auto">
|
||||||
{messages.map((message, index) => {
|
{requestMessages.map((message, index) => {
|
||||||
const role = message.role || 'user'
|
const role = message.role || 'user'
|
||||||
const isUser = role === 'user'
|
const isUser = role === 'user'
|
||||||
const isAssistant = role === 'assistant'
|
const isAssistant = role === 'assistant'
|
||||||
@@ -121,7 +250,7 @@ function RequestMessages({ item, t }) {
|
|||||||
? t('chatHistory.role.user')
|
? t('chatHistory.role.user')
|
||||||
: (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system')))
|
: (isAssistant ? t('chatHistory.role.assistant') : (isTool ? t('chatHistory.role.tool') : t('chatHistory.role.system')))
|
||||||
return (
|
return (
|
||||||
<div key={`${role}-${index}`} className="flex gap-4">
|
<div key={`${role}-${index}`} className={clsx('flex gap-4', isUser && 'flex-row-reverse justify-start')}>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
|
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0 border border-border',
|
||||||
isUser
|
isUser
|
||||||
@@ -133,7 +262,7 @@ function RequestMessages({ item, t }) {
|
|||||||
: <Bot className="w-4 h-4 text-foreground" />}
|
: <Bot className="w-4 h-4 text-foreground" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-w-[88%] lg:max-w-[78%] text-left">
|
<div className="max-w-[88%] lg:max-w-[78%] text-left">
|
||||||
<div className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-2 px-1">
|
<div className={clsx('text-[11px] uppercase tracking-[0.12em] text-muted-foreground mb-2 px-1', isUser && 'text-right')}>
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
<div className={clsx(
|
<div className={clsx(
|
||||||
@@ -205,13 +334,15 @@ function HistoryTextView({ item, t }) {
|
|||||||
|
|
||||||
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) {
|
function DetailConversation({ selectedItem, t, viewMode, detailScrollRef, assistantStartRef, bottomButtonClassName }) {
|
||||||
if (!selectedItem) return null
|
if (!selectedItem) return null
|
||||||
|
const listModeState = viewMode === 'list' ? buildListModeMessages(selectedItem, t) : null
|
||||||
|
const showHistoryAtTop = viewMode !== 'list' || !listModeState?.historyMerged
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HistoryTextView item={selectedItem} t={t} />
|
{showHistoryAtTop && <HistoryTextView item={selectedItem} t={t} />}
|
||||||
|
|
||||||
{viewMode === 'list'
|
{viewMode === 'list'
|
||||||
? <RequestMessages item={selectedItem} t={t} />
|
? <RequestMessages item={selectedItem} t={t} messages={listModeState?.messages} />
|
||||||
: <MergedPromptView item={selectedItem} t={t} />}
|
: <MergedPromptView item={selectedItem} t={t} />}
|
||||||
|
|
||||||
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
|
<div ref={assistantStartRef} className="flex gap-4 max-w-4xl mx-auto">
|
||||||
@@ -410,12 +541,12 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefreshReady) return undefined
|
if (!autoRefreshReady || limit === DISABLED_LIMIT) return undefined
|
||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
loadList({ mode: 'silent', announceError: false })
|
loadList({ mode: 'silent', announceError: false })
|
||||||
}, 5000)
|
}, 5000)
|
||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [autoRefreshReady])
|
}, [autoRefreshReady, limit])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined
|
if (!autoRefreshReady || !selectedId || selectedSummary?.status !== 'streaming') return undefined
|
||||||
@@ -494,7 +625,12 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
|
|||||||
setLimit(resolvedLimit)
|
setLimit(resolvedLimit)
|
||||||
listETagRef.current = ''
|
listETagRef.current = ''
|
||||||
syncItems(Array.isArray(data.items) ? data.items : [])
|
syncItems(Array.isArray(data.items) ? data.items : [])
|
||||||
onMessage?.('success', t('chatHistory.limitUpdated', { limit: resolvedLimit === DISABLED_LIMIT ? t('chatHistory.off') : resolvedLimit }))
|
onMessage?.(
|
||||||
|
'success',
|
||||||
|
resolvedLimit === DISABLED_LIMIT
|
||||||
|
? t('chatHistory.disabledSuccess')
|
||||||
|
: t('chatHistory.limitUpdated', { limit: resolvedLimit })
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onMessage?.('error', error.message || t('chatHistory.updateLimitFailed'))
|
onMessage?.('error', error.message || t('chatHistory.updateLimitFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -573,6 +709,12 @@ export default function ChatHistoryContainer({ authFetch, onMessage }) {
|
|||||||
openMobileDetail(itemId, event)
|
openMobileDetail(itemId, event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (itemId === selectedId) {
|
||||||
|
detailETagRef.current = ''
|
||||||
|
setSelectedDetail(null)
|
||||||
|
loadDetail(itemId, { announceError: false })
|
||||||
|
return
|
||||||
|
}
|
||||||
setPendingJumpToAssistant(true)
|
setPendingJumpToAssistant(true)
|
||||||
setSelectedId(itemId)
|
setSelectedId(itemId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"deleteSuccess": "Conversation deleted.",
|
"deleteSuccess": "Conversation deleted.",
|
||||||
"deleteFailed": "Failed to delete conversation.",
|
"deleteFailed": "Failed to delete conversation.",
|
||||||
"updateLimitFailed": "Failed to update retention limit.",
|
"updateLimitFailed": "Failed to update retention limit.",
|
||||||
|
"disabledSuccess": "Conversation history saving disabled.",
|
||||||
"limitUpdated": "Retention limit updated to {limit}",
|
"limitUpdated": "Retention limit updated to {limit}",
|
||||||
"listTitle": "History",
|
"listTitle": "History",
|
||||||
"detailTitle": "Details",
|
"detailTitle": "Details",
|
||||||
|
|||||||
@@ -264,6 +264,7 @@
|
|||||||
"deleteSuccess": "对话记录已删除",
|
"deleteSuccess": "对话记录已删除",
|
||||||
"deleteFailed": "删除对话记录失败",
|
"deleteFailed": "删除对话记录失败",
|
||||||
"updateLimitFailed": "更新保留条数失败",
|
"updateLimitFailed": "更新保留条数失败",
|
||||||
|
"disabledSuccess": "已关闭对话历史记录",
|
||||||
"limitUpdated": "保留条数已更新为 {limit}",
|
"limitUpdated": "保留条数已更新为 {limit}",
|
||||||
"listTitle": "历史列表",
|
"listTitle": "历史列表",
|
||||||
"detailTitle": "对话详情",
|
"detailTitle": "对话详情",
|
||||||
|
|||||||
Reference in New Issue
Block a user