feat: 增加token 统计
This commit is contained in:
parent
30fc1779f4
commit
6f9bff1f1f
|
|
@ -26,7 +26,12 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="message-footer">
|
<div class="message-footer">
|
||||||
<span class="token-count" v-if="tokenCount">{{ tokenCount }} tokens</span>
|
<span class="token-info" v-if="usage">
|
||||||
|
<span class="token-item" v-if="usage.prompt_tokens">输入: {{ usage.prompt_tokens }}</span>
|
||||||
|
<span class="token-item" v-if="usage.completion_tokens">输出: {{ usage.completion_tokens }}</span>
|
||||||
|
<span class="token-item total" v-if="usage.total_tokens">总计: {{ usage.total_tokens }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="token-count" v-else-if="tokenCount">{{ tokenCount }} tokens</span>
|
||||||
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
<span class="message-time">{{ formatTime(createdAt) }}</span>
|
||||||
<button v-if="role === 'assistant'" class="ghost-btn accent" @click="copyContent" title="复制">
|
<button v-if="role === 'assistant'" class="ghost-btn accent" @click="copyContent" title="复制">
|
||||||
<span v-html="copyIcon"></span>
|
<span v-html="copyIcon"></span>
|
||||||
|
|
@ -50,6 +55,7 @@ const props = defineProps({
|
||||||
toolCalls: { type: Array, default: () => [] },
|
toolCalls: { type: Array, default: () => [] },
|
||||||
processSteps: { type: Array, default: () => [] },
|
processSteps: { type: Array, default: () => [] },
|
||||||
tokenCount: { type: Number, default: 0 },
|
tokenCount: { type: Number, default: 0 },
|
||||||
|
usage: { type: Object, default: null },
|
||||||
createdAt: { type: String, default: '' },
|
createdAt: { type: String, default: '' },
|
||||||
deletable: { type: Boolean, default: false },
|
deletable: { type: Boolean, default: false },
|
||||||
attachments: { type: Array, default: () => [] },
|
attachments: { type: Array, default: () => [] },
|
||||||
|
|
@ -131,6 +137,25 @@ const trashIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" s
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-item {
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--bg-code);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-item.total {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.token-count,
|
.token-count,
|
||||||
.message-time {
|
.message-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
|
||||||
|
|
@ -276,6 +276,8 @@ const alertIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" s
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thinking .step-header:hover,
|
.thinking .step-header:hover,
|
||||||
|
|
@ -370,8 +372,9 @@ const alertIcon = `<svg viewBox="0 0 24 24" width="16" height="16" fill="none" s
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow.open {
|
.arrow.open {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
:tool-calls="msg.tool_calls"
|
:tool-calls="msg.tool_calls"
|
||||||
:process-steps="msg.process_steps"
|
:process-steps="msg.process_steps"
|
||||||
:token-count="msg.token_count"
|
:token-count="msg.token_count"
|
||||||
|
:usage="msg.usage"
|
||||||
:created-at="msg.created_at"
|
:created-at="msg.created_at"
|
||||||
:deletable="msg.role === 'user'"
|
:deletable="msg.role === 'user'"
|
||||||
:attachments="msg.attachments"
|
:attachments="msg.attachments"
|
||||||
|
|
@ -233,13 +234,15 @@ const sendMessage = async () => {
|
||||||
streamingMessage.value.process_steps.push(step)
|
streamingMessage.value.process_steps.push(step)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDone: () => {
|
onDone: (data) => {
|
||||||
// 完成,添加到消息列表
|
// 完成,添加到消息列表
|
||||||
autoScroll.value = true
|
autoScroll.value = true
|
||||||
if (streamingMessage.value) {
|
if (streamingMessage.value) {
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
...streamingMessage.value,
|
...streamingMessage.value,
|
||||||
created_at: new Date().toISOString()
|
created_at: new Date().toISOString(),
|
||||||
|
token_count: data.token_count,
|
||||||
|
usage: data.usage
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果标题为空,自动用第一条用户消息作为标题
|
// 如果标题为空,自动用第一条用户消息作为标题
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,7 @@ class Message(Base):
|
||||||
role: Mapped[str] = mapped_column(String(16), nullable=False) # user, assistant, system, tool
|
role: Mapped[str] = mapped_column(String(16), nullable=False) # user, assistant, system, tool
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
token_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
usage: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # JSON string for usage info
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=local_now)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
|
@ -184,6 +185,13 @@ class Message(Base):
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Parse usage JSON
|
||||||
|
if self.usage:
|
||||||
|
try:
|
||||||
|
result["usage"] = json.loads(self.usage)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
result["usage"] = None
|
||||||
|
|
||||||
# Parse content JSON
|
# Parse content JSON
|
||||||
try:
|
try:
|
||||||
content_obj = json.loads(self.content) if self.content else {}
|
content_obj = json.loads(self.content) if self.content else {}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ def list_conversations(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get conversation list"""
|
"""Get conversation list"""
|
||||||
|
import json
|
||||||
query = db.query(Conversation).filter(Conversation.user_id == current_user.id)
|
query = db.query(Conversation).filter(Conversation.user_id == current_user.id)
|
||||||
result = paginate(query.order_by(Conversation.updated_at.desc()), page, page_size)
|
result = paginate(query.order_by(Conversation.updated_at.desc()), page, page_size)
|
||||||
|
|
||||||
|
|
@ -56,6 +57,23 @@ def list_conversations(
|
||||||
).order_by(Message.created_at).first()
|
).order_by(Message.created_at).first()
|
||||||
if first_msg:
|
if first_msg:
|
||||||
conv_dict['first_message'] = first_msg.content[:50] + ('...' if len(first_msg.content) > 50 else '')
|
conv_dict['first_message'] = first_msg.content[:50] + ('...' if len(first_msg.content) > 50 else '')
|
||||||
|
|
||||||
|
# Calculate total tokens from all assistant messages in this conversation
|
||||||
|
assistant_messages = db.query(Message).filter(
|
||||||
|
Message.conversation_id == c.id,
|
||||||
|
Message.role == 'assistant'
|
||||||
|
).all()
|
||||||
|
total_tokens = 0
|
||||||
|
for msg in assistant_messages:
|
||||||
|
total_tokens += msg.token_count or 0
|
||||||
|
# Also try to get usage from the usage field
|
||||||
|
if msg.usage:
|
||||||
|
try:
|
||||||
|
usage_obj = json.loads(msg.usage)
|
||||||
|
total_tokens = usage_obj.get("total_tokens", total_tokens)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
conv_dict['token_count'] = total_tokens
|
||||||
items.append(conv_dict)
|
items.append(conv_dict)
|
||||||
|
|
||||||
return success_response(data={
|
return success_response(data={
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"""Chat service module"""
|
"""Chat service module"""
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
import logging
|
||||||
from typing import List, Dict, Any, AsyncGenerator, Optional
|
from typing import List, Dict, Any, AsyncGenerator, Optional
|
||||||
|
|
||||||
from luxx.models import Conversation, Message
|
from luxx.models import Conversation, Message
|
||||||
|
|
@ -9,7 +10,7 @@ from luxx.tools.core import registry
|
||||||
from luxx.services.llm_client import LLMClient
|
from luxx.services.llm_client import LLMClient
|
||||||
from luxx.config import config
|
from luxx.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
# Maximum iterations to prevent infinite loops
|
# Maximum iterations to prevent infinite loops
|
||||||
MAX_ITERATIONS = 10
|
MAX_ITERATIONS = 10
|
||||||
|
|
||||||
|
|
@ -130,6 +131,13 @@ class ChatService:
|
||||||
all_tool_results = []
|
all_tool_results = []
|
||||||
step_index = 0
|
step_index = 0
|
||||||
|
|
||||||
|
# Token usage tracking
|
||||||
|
total_usage = {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"total_tokens": 0
|
||||||
|
}
|
||||||
|
|
||||||
# Global step IDs for thinking and text (persist across iterations)
|
# Global step IDs for thinking and text (persist across iterations)
|
||||||
thinking_step_id = None
|
thinking_step_id = None
|
||||||
thinking_step_idx = None
|
thinking_step_idx = None
|
||||||
|
|
@ -186,6 +194,13 @@ class ChatService:
|
||||||
yield _sse_event("error", {"content": f"Failed to parse response: {data_str}"})
|
yield _sse_event("error", {"content": f"Failed to parse response: {data_str}"})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 提取 API 返回的 usage 信息
|
||||||
|
if "usage" in chunk:
|
||||||
|
usage = chunk["usage"]
|
||||||
|
total_usage["prompt_tokens"] = usage.get("prompt_tokens", 0)
|
||||||
|
total_usage["completion_tokens"] = usage.get("completion_tokens", 0)
|
||||||
|
total_usage["total_tokens"] = usage.get("total_tokens", 0)
|
||||||
|
|
||||||
# Check for error in response
|
# Check for error in response
|
||||||
if "error" in chunk:
|
if "error" in chunk:
|
||||||
error_msg = chunk["error"].get("message", str(chunk["error"]))
|
error_msg = chunk["error"].get("message", str(chunk["error"]))
|
||||||
|
|
@ -362,18 +377,26 @@ class ChatService:
|
||||||
|
|
||||||
# No tool calls - final iteration, save message
|
# No tool calls - final iteration, save message
|
||||||
msg_id = str(uuid.uuid4())
|
msg_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# 使用 API 返回的真实 completion_tokens,如果 API 没返回则降级使用估算值
|
||||||
|
actual_token_count = total_usage.get("completion_tokens", 0) or len(full_content) // 4
|
||||||
|
logger.info(f"[TOKEN] total_usage: {total_usage}, actual_token_count: {actual_token_count}")
|
||||||
|
|
||||||
self._save_message(
|
self._save_message(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
msg_id,
|
msg_id,
|
||||||
full_content,
|
full_content,
|
||||||
all_tool_calls,
|
all_tool_calls,
|
||||||
all_tool_results,
|
all_tool_results,
|
||||||
all_steps
|
all_steps,
|
||||||
|
actual_token_count,
|
||||||
|
total_usage
|
||||||
)
|
)
|
||||||
|
|
||||||
yield _sse_event("done", {
|
yield _sse_event("done", {
|
||||||
"message_id": msg_id,
|
"message_id": msg_id,
|
||||||
"token_count": len(full_content) // 4
|
"token_count": actual_token_count,
|
||||||
|
"usage": total_usage
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -386,7 +409,9 @@ class ChatService:
|
||||||
full_content,
|
full_content,
|
||||||
all_tool_calls,
|
all_tool_calls,
|
||||||
all_tool_results,
|
all_tool_results,
|
||||||
all_steps
|
all_steps,
|
||||||
|
actual_token_count,
|
||||||
|
total_usage
|
||||||
)
|
)
|
||||||
yield _sse_event("error", {"content": "Exceeded maximum tool call iterations"})
|
yield _sse_event("error", {"content": "Exceeded maximum tool call iterations"})
|
||||||
|
|
||||||
|
|
@ -400,7 +425,9 @@ class ChatService:
|
||||||
full_content: str,
|
full_content: str,
|
||||||
all_tool_calls: list,
|
all_tool_calls: list,
|
||||||
all_tool_results: list,
|
all_tool_results: list,
|
||||||
all_steps: list
|
all_steps: list,
|
||||||
|
token_count: int = 0,
|
||||||
|
usage: dict = None
|
||||||
):
|
):
|
||||||
"""Save the assistant message to database."""
|
"""Save the assistant message to database."""
|
||||||
from luxx.database import SessionLocal
|
from luxx.database import SessionLocal
|
||||||
|
|
@ -420,7 +447,8 @@ class ChatService:
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
role="assistant",
|
role="assistant",
|
||||||
content=json.dumps(content_json, ensure_ascii=False),
|
content=json.dumps(content_json, ensure_ascii=False),
|
||||||
token_count=len(full_content) // 4
|
token_count=token_count,
|
||||||
|
usage=json.dumps(usage) if usage else None
|
||||||
)
|
)
|
||||||
db.add(msg)
|
db.add(msg)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue