Files
playchat/frontend/src/components/ChatWindow.vue
T
colcaroliandClaude Opus 5 366320c43a Show tool and web search activity in the chat UI
Tool calls were invisible until the answer arrived: the only trace was the
sources list, and only for web searches. Surface them as they happen instead.

Add llm.stream_tool_calls(), an incremental version of resolve_tool_calls()
that yields a start and a completion event per tool call and ends with a
"resolved" event carrying the previous return value. resolve_tool_calls() is
kept as a thin wrapper that drops the progress events.

api/chat.py relays those events as a new SSE "tool" event, and wraps the
search-summary/knowledge-save step in one of the same shape, since it is a
noticeable pause of its own.

On the front end, ToolActivityList.vue renders one row per tool above the
assistant bubble (label, target, spinner while running, short result when
done). The store merges events by id and marks anything still running as
interrupted when the stream ends early. Display only - not persisted, so the
rows disappear when the session is reloaded, same as sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:11:00 +09:00

208 lines
5.5 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue'
import ToolActivityList from './ToolActivityList.vue'
import type { Source } from '../api/client'
const store = useChatStore()
const scrollEl = ref<HTMLElement | null>(null)
// 要約済みメッセージの最後の位置。ここに「ここから先がモデルに渡る」区切りを描く。
const lastArchivedIndex = computed(() => {
for (let i = store.messages.length - 1; i >= 0; i--) {
if (store.messages[i].archived) return i
}
return -1
})
function uniqueSources(sources: Source[]): Source[] {
const seen = new Set<string>()
return sources.filter((s) => {
if (seen.has(s.url)) return false
seen.add(s.url)
return true
})
}
watch(
() => store.messages.length,
async () => {
await nextTick()
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
},
)
</script>
<template>
<div class="chat-window" ref="scrollEl">
<div v-if="store.messages.length === 0" class="empty">
メッセージを送信してチャットを開始してください
</div>
<template v-for="(msg, i) in store.messages" :key="i">
<div
:class="['message-wrap', msg.role, { archived: msg.archived }]"
:title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
>
<ToolActivityList v-if="msg.tools && msg.tools.length > 0" :tools="msg.tools" />
<div :class="['message', msg.role, { error: msg.error }]">
<div class="bubble">
<!-- ユーザーメッセージはプレーンテキスト -->
<template v-if="msg.role === 'user'">{{ msg.content }}</template>
<!-- アシスタントはMarkdownレンダリング -->
<template v-else>
<MarkdownContent :content="msg.content" :pending="msg.pending" />
<span v-if="msg.pending && !msg.content" class="cursor">▌</span>
<button v-if="msg.error" class="retry-btn" :disabled="store.isStreaming" @click="store.retryMessage(i)">
🔄 再送信
</button>
</template>
</div>
</div>
<div v-if="msg.tags && msg.tags.length > 0" class="tags">
<span v-for="tag in msg.tags" :key="tag" class="tag">#{{ tag }}</span>
</div>
<div v-if="msg.sources && msg.sources.length > 0" class="sources">
<span class="sources-label">🔍 参照元:</span>
<a
v-for="src in uniqueSources(msg.sources)"
:key="src.url"
:href="src.url"
target="_blank"
rel="noopener noreferrer"
class="source-link"
>{{ src.title || src.url }}</a>
</div>
</div>
<div v-if="i === lastArchivedIndex" class="archive-divider">
<span>ここまでは要約済み — 履歴として残っていますが、モデルには要約だけが渡ります</span>
</div>
</template>
</div>
</template>
<style scoped>
.chat-window {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
display: flex;
flex-direction: column;
gap: 12px;
}
.empty {
margin: auto;
color: #555;
font-size: 14px;
}
.message-wrap { display: flex; flex-direction: column; gap: 4px; }
.message-wrap.user { align-items: flex-end; }
.message-wrap.assistant { align-items: flex-start; }
/* 要約済み(モデルには渡らない)メッセージ。読めるが現役の発言と区別できる程度に落とす */
.message-wrap.archived { opacity: 0.45; }
.message-wrap.archived:hover { opacity: 0.75; }
.archive-divider {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
color: #777;
font-size: 11px;
}
.archive-divider::before,
.archive-divider::after {
content: '';
flex: 1;
border-top: 1px dashed #3a3a3a;
}
.message { display: flex; width: 100%; }
.message.user { justify-content: flex-end; }
.message.assistant { justify-content: flex-start; }
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
max-width: 72%;
}
.tag {
font-size: 11px;
color: #93c5fd;
background: #1d3557;
border-radius: 10px;
padding: 2px 8px;
}
.sources {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
max-width: 72%;
font-size: 11px;
}
.sources-label {
color: #888;
}
.source-link {
color: #6ee7b7;
background: #103027;
border-radius: 10px;
padding: 2px 8px;
text-decoration: none;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-link:hover {
text-decoration: underline;
}
.bubble {
max-width: 72%;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
word-break: break-word;
}
.user .bubble {
background: #2563eb;
color: #fff;
border-bottom-right-radius: 4px;
white-space: pre-wrap;
}
.assistant .bubble {
background: #2a2a2a;
color: #eee;
border-bottom-left-radius: 4px;
}
.message.error .bubble {
background: #3b1a1a;
color: #f87171;
border: 1px solid #7f1d1d;
}
.cursor {
display: inline-block;
animation: blink 0.8s step-end infinite;
}
.retry-btn {
display: block;
margin-top: 8px;
background: #2a1414;
color: #f87171;
border: 1px solid #7f1d1d;
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
}
.retry-btn:hover:not(:disabled) {
background: #3b1a1a;
}
.retry-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@keyframes blink { 50% { opacity: 0; } }
</style>