feat: 话题添加描述字段,工具消息按 tool_call_id 合并展示

- TopicSummary 新增 description 字段,侧边栏优先显示描述
- ToolPanel 使用 toolCallId 将 tool_call 和 tool_result 配对合并展示
- 保存消息时同步更新 topics 表的 message_count 和 last_active_at
- ChatMessage 新增 toolCallId 字段

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
oudecheng 2026-05-28 14:30:21 +08:00
parent 44e82e8473
commit 598d425c28
8 changed files with 66 additions and 19 deletions

View File

@ -13,6 +13,7 @@ pub struct TopicSummary {
pub topic_id: String, pub topic_id: String,
pub session_id: String, pub session_id: String,
pub title: String, pub title: String,
pub description: Option<String>,
pub message_count: i64, pub message_count: i64,
pub created_at: i64, pub created_at: i64,
pub last_active_at: i64, pub last_active_at: i64,
@ -73,6 +74,7 @@ async fn handle_list_topics(
topic_id: t.id, topic_id: t.id,
session_id: t.session_id, session_id: t.session_id,
title: t.title, title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count, message_count: t.message_count,
created_at: t.created_at, created_at: t.created_at,
last_active_at: t.last_active_at, last_active_at: t.last_active_at,

View File

@ -107,6 +107,7 @@ async fn handle_create_session(
topic_id: t.id, topic_id: t.id,
session_id: t.session_id, session_id: t.session_id,
title: t.title, title: t.title,
description: t.description.filter(|d| !d.is_empty()),
message_count: t.message_count, message_count: t.message_count,
created_at: t.created_at, created_at: t.created_at,
last_active_at: t.last_active_at, last_active_at: t.last_active_at,

View File

@ -32,6 +32,8 @@ pub struct TopicSummary {
pub message_count: i64, pub message_count: i64,
pub created_at: i64, pub created_at: i64,
pub last_active_at: i64, pub last_active_at: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -579,6 +579,13 @@ impl SessionStore {
params![session_id, now, if is_user_message { 1 } else { 0 }], params![session_id, now, if is_user_message { 1 } else { 0 }],
)?; )?;
if let Some(tid) = topic_id {
tx.execute(
"UPDATE topics SET message_count = message_count + 1, last_active_at = ?2 WHERE id = ?1",
params![tid, now],
)?;
}
tx.commit()?; tx.commit()?;
Ok(()) Ok(())
} }

View File

@ -1,5 +1,5 @@
import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react' import { ChevronDown, ChevronRight, Play, Check, AlertTriangle, Terminal } from 'lucide-react'
import { useState } from 'react' import { useState, useMemo } from 'react'
import type { ChatMessage } from '../../types/protocol' import type { ChatMessage } from '../../types/protocol'
interface ToolPanelProps { interface ToolPanelProps {
@ -7,25 +7,54 @@ interface ToolPanelProps {
} }
interface ToolCallItem { interface ToolCallItem {
id: string toolCallId: string
toolName: string toolName: string
status: 'calling' | 'result' | 'pending' status: 'calling' | 'result' | 'pending'
arguments?: unknown arguments?: unknown
content: string resultContent: string
callContent: string
}
function mergeToolMessages(messages: ChatMessage[]): ToolCallItem[] {
const map = new Map<string, ToolCallItem>()
for (const m of messages) {
if (m.role !== 'tool' || !m.type?.startsWith('tool_')) continue
const key = m.toolCallId || m.id
let entry = map.get(key)
if (!entry) {
entry = {
toolCallId: key,
toolName: m.toolName || 'Unknown',
status: 'calling',
arguments: undefined,
resultContent: '',
callContent: '',
}
map.set(key, entry)
}
if (m.type === 'tool_call') {
entry.arguments = m.arguments
entry.callContent = m.content
} else if (m.type === 'tool_result') {
entry.status = 'result'
entry.resultContent = m.content
} else if (m.type === 'tool_pending') {
entry.status = 'pending'
entry.resultContent = m.content
}
}
return Array.from(map.values())
} }
export function ToolPanel({ messages }: ToolPanelProps) { export function ToolPanel({ messages }: ToolPanelProps) {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set()) const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set())
const toolCalls: ToolCallItem[] = messages const toolCalls = useMemo(() => mergeToolMessages(messages), [messages])
.filter((m) => m.role === 'tool' && m.type && m.type.startsWith('tool_'))
.map((m) => ({
id: m.id,
toolName: m.toolName || 'Unknown',
status: m.type === 'tool_call' ? 'calling' : m.type === 'tool_result' ? 'result' : 'pending',
arguments: m.arguments,
content: m.content,
}))
const toggleExpand = (id: string) => { const toggleExpand = (id: string) => {
setExpandedTools((prev) => { setExpandedTools((prev) => {
@ -42,7 +71,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
const getStatusIcon = (status: ToolCallItem['status']) => { const getStatusIcon = (status: ToolCallItem['status']) => {
switch (status) { switch (status) {
case 'calling': case 'calling':
return <Play className="h-3 w-3 text-amber-400" /> return <Play className="h-3 w-3 text-amber-400 animate-pulse" />
case 'result': case 'result':
return <Check className="h-3 w-3 text-emerald-400" /> return <Check className="h-3 w-3 text-emerald-400" />
case 'pending': case 'pending':
@ -91,11 +120,11 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<div className="space-y-2"> <div className="space-y-2">
{toolCalls.map((tool) => ( {toolCalls.map((tool) => (
<div <div
key={tool.id} key={tool.toolCallId}
className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden" className="rounded-xl border border-white/8 bg-[#1a1a25]/50 text-sm overflow-hidden"
> >
<button <button
onClick={() => toggleExpand(tool.id)} onClick={() => toggleExpand(tool.toolCallId)}
className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors" className="flex w-full items-center justify-between px-3 py-2.5 hover:bg-white/5 transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -105,13 +134,13 @@ export function ToolPanel({ messages }: ToolPanelProps) {
{getStatusText(tool.status)} {getStatusText(tool.status)}
</span> </span>
</div> </div>
{expandedTools.has(tool.id) ? ( {expandedTools.has(tool.toolCallId) ? (
<ChevronDown className="h-4 w-4 text-zinc-500" /> <ChevronDown className="h-4 w-4 text-zinc-500" />
) : ( ) : (
<ChevronRight className="h-4 w-4 text-zinc-500" /> <ChevronRight className="h-4 w-4 text-zinc-500" />
)} )}
</button> </button>
{expandedTools.has(tool.id) && ( {expandedTools.has(tool.toolCallId) && (
<div className="border-t border-white/8 px-3 py-2 bg-black/20"> <div className="border-t border-white/8 px-3 py-2 bg-black/20">
{tool.arguments ? ( {tool.arguments ? (
<div className="mb-2"> <div className="mb-2">
@ -122,7 +151,7 @@ export function ToolPanel({ messages }: ToolPanelProps) {
<div> <div>
<div className="text-xs font-medium text-zinc-500 mb-1">:</div> <div className="text-xs font-medium text-zinc-500 mb-1">:</div>
<div className="max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 text-xs whitespace-pre-wrap text-zinc-400 font-mono"> <div className="max-h-32 overflow-y-auto rounded-lg bg-black/40 p-2 text-xs whitespace-pre-wrap text-zinc-400 font-mono">
{tool.content} {tool.resultContent || tool.callContent}
</div> </div>
</div> </div>
</div> </div>

View File

@ -102,7 +102,7 @@ export function TopicList({
<div className={`truncate font-medium ${ <div className={`truncate font-medium ${
topic.id === currentTopicId ? 'text-[#00f0ff]' : 'text-zinc-300' topic.id === currentTopicId ? 'text-[#00f0ff]' : 'text-zinc-300'
}`}> }`}>
{topic.title} {topic.description || topic.title}
</div> </div>
<div className="flex items-center gap-3 mt-1.5"> <div className="flex items-center gap-3 mt-1.5">
<span className="text-xs text-zinc-500 flex items-center gap-1"> <span className="text-xs text-zinc-500 flex items-center gap-1">

View File

@ -125,6 +125,7 @@ export function useChat(): UseChatReturn {
id: t.topic_id, id: t.topic_id,
session_id: t.session_id, session_id: t.session_id,
title: t.title, title: t.title,
description: t.description || undefined,
message_count: Number(t.message_count), message_count: Number(t.message_count),
created_at: t.created_at, created_at: t.created_at,
updated_at: t.last_active_at, updated_at: t.last_active_at,
@ -165,6 +166,7 @@ export function useChat(): UseChatReturn {
timestamp: Date.now(), timestamp: Date.now(),
type: 'tool_call', type: 'tool_call',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
arguments: msg.arguments, arguments: msg.arguments,
}, },
]) ])
@ -182,6 +184,7 @@ export function useChat(): UseChatReturn {
timestamp: Date.now(), timestamp: Date.now(),
type: 'tool_result', type: 'tool_result',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
}, },
]) ])
break break
@ -198,6 +201,7 @@ export function useChat(): UseChatReturn {
timestamp: Date.now(), timestamp: Date.now(),
type: 'tool_pending', type: 'tool_pending',
toolName: msg.tool_name, toolName: msg.tool_name,
toolCallId: msg.tool_call_id,
}, },
]) ])
break break

View File

@ -123,6 +123,7 @@ export interface TopicSummary {
topic_id: string topic_id: string
session_id: string session_id: string
title: string title: string
description?: string
message_count: number message_count: number
created_at: number created_at: number
last_active_at: number last_active_at: number
@ -249,6 +250,7 @@ export interface ChatMessage {
timestamp: number timestamp: number
type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending' type?: 'message' | 'tool_call' | 'tool_result' | 'tool_pending'
toolName?: string toolName?: string
toolCallId?: string
arguments?: unknown arguments?: unknown
attachments?: Attachment[] attachments?: Attachment[]
} }