mirror of
https://github.com/hangwin/mcp-chrome.git
synced 2026-09-24 23:24:04 +08:00
fix: ccr bug fix
This commit is contained in:
@@ -22,7 +22,9 @@
|
||||
"Bash(curl:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(node:*)",
|
||||
"Bash(cat:*)"
|
||||
"Bash(cat:*)",
|
||||
"WebSearch",
|
||||
"Bash(xargs cat:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="agent-theme relative h-full" :data-agent-theme="themeState.theme.value">
|
||||
<AgentChatShell :error-message="chat.errorMessage.value">
|
||||
<AgentChatShell :error-message="chat.errorMessage.value" :usage="chat.lastUsage.value">
|
||||
<!-- Header -->
|
||||
<template #header>
|
||||
<AgentTopBar
|
||||
@@ -50,6 +50,7 @@
|
||||
:selected-project-id="projects.selectedProjectId.value"
|
||||
:selected-cli="selectedCli"
|
||||
:model="model"
|
||||
:use-ccr="useCcr"
|
||||
:project-root-override="projects.projectRootOverride.value"
|
||||
:engines="server.engines.value"
|
||||
:is-picking="isPickingDirectory"
|
||||
@@ -59,6 +60,7 @@
|
||||
@project:new="handleNewProject"
|
||||
@cli:update="selectedCli = $event"
|
||||
@model:update="model = $event"
|
||||
@ccr:update="useCcr = $event"
|
||||
@root:update="projects.projectRootOverride.value = $event"
|
||||
@save="handleSaveSettings"
|
||||
/>
|
||||
@@ -97,10 +99,31 @@ import {
|
||||
AgentSettingsMenu,
|
||||
} from './agent-chat';
|
||||
|
||||
// Model utilities
|
||||
import { getModelsForCli } from '@/common/agent-models';
|
||||
|
||||
// Local UI state
|
||||
const selectedCli = ref('');
|
||||
const model = ref('');
|
||||
const useCcr = ref(false);
|
||||
const isSavingPreference = ref(false);
|
||||
|
||||
/**
|
||||
* Get normalized model value that is valid for the current CLI.
|
||||
* Returns empty string if:
|
||||
* - No CLI selected (use server default)
|
||||
* - Model is invalid for selected CLI
|
||||
*/
|
||||
function getNormalizedModel(): string {
|
||||
const trimmedModel = model.value.trim();
|
||||
if (!trimmedModel) return '';
|
||||
// No CLI selected = don't override model, let server use default
|
||||
if (!selectedCli.value) return '';
|
||||
const models = getModelsForCli(selectedCli.value);
|
||||
if (models.length === 0) return ''; // Unknown CLI
|
||||
const isValid = models.some((m) => m.id === trimmedModel);
|
||||
return isValid ? trimmedModel : '';
|
||||
}
|
||||
const isPickingDirectory = ref(false);
|
||||
const projectMenuOpen = ref(false);
|
||||
const settingsMenuOpen = ref(false);
|
||||
@@ -202,6 +225,7 @@ async function handleProjectSelect(projectId: string): Promise<void> {
|
||||
if (project) {
|
||||
selectedCli.value = project.preferredCli ?? '';
|
||||
model.value = project.selectedModel ?? '';
|
||||
useCcr.value = project.useCcr ?? false;
|
||||
}
|
||||
closeMenus();
|
||||
}
|
||||
@@ -211,11 +235,14 @@ async function handleNewProject(): Promise<void> {
|
||||
try {
|
||||
const path = await projects.pickDirectory();
|
||||
if (path) {
|
||||
const dirName = path.split(/[/\\]/).pop() || 'New Project';
|
||||
// Extract directory name from path, handling trailing slashes
|
||||
const segments = path.split(/[/\\]/).filter((s) => s.length > 0);
|
||||
const dirName = segments.pop() || 'New Project';
|
||||
const project = await projects.createProjectFromPath(path, dirName);
|
||||
if (project) {
|
||||
selectedCli.value = project.preferredCli ?? '';
|
||||
model.value = project.selectedModel ?? '';
|
||||
useCcr.value = project.useCcr ?? false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -229,8 +256,15 @@ async function handleSaveSettings(): Promise<void> {
|
||||
|
||||
isSavingPreference.value = true;
|
||||
try {
|
||||
await projects.saveProjectPreference(selectedCli.value, model.value);
|
||||
// Use normalized model to ensure valid value is saved
|
||||
const normalizedModel = getNormalizedModel();
|
||||
// Only save CCR if Claude CLI is selected
|
||||
const normalizedCcr = selectedCli.value === 'claude' ? useCcr.value : false;
|
||||
await projects.saveProjectPreference(selectedCli.value, normalizedModel, normalizedCcr);
|
||||
await projects.saveProjectRootOverride();
|
||||
// Sync local state with normalized values
|
||||
model.value = normalizedModel;
|
||||
useCcr.value = normalizedCcr;
|
||||
} finally {
|
||||
isSavingPreference.value = false;
|
||||
closeMenus();
|
||||
@@ -252,9 +286,12 @@ function handleAttachmentAdd(): void {
|
||||
async function handleSend(): Promise<void> {
|
||||
chat.attachments.value = attachments.attachments.value;
|
||||
|
||||
// Use normalized model to ensure valid value is sent
|
||||
const normalizedModel = getNormalizedModel();
|
||||
|
||||
await chat.send({
|
||||
cliPreference: selectedCli.value || undefined,
|
||||
model: model.value || undefined,
|
||||
model: normalizedModel || undefined,
|
||||
projectId: projects.selectedProjectId.value || undefined,
|
||||
projectRoot: projects.projectRootOverride.value || undefined,
|
||||
});
|
||||
@@ -296,6 +333,7 @@ onMounted(async () => {
|
||||
if (project) {
|
||||
selectedCli.value = project.preferredCli ?? '';
|
||||
model.value = project.selectedModel ?? '';
|
||||
useCcr.value = project.useCcr ?? false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-4
@@ -55,24 +55,53 @@
|
||||
|
||||
<slot name="composer" />
|
||||
|
||||
<!-- Version label -->
|
||||
<!-- Usage & Version label -->
|
||||
<div
|
||||
class="text-[10px] text-center mt-2 font-medium tracking-wide"
|
||||
class="text-[10px] text-center mt-2 font-medium tracking-wide flex items-center justify-center gap-2"
|
||||
:style="{ color: 'var(--ac-text-subtle)' }"
|
||||
>
|
||||
Claude Code Preview
|
||||
<template v-if="usage">
|
||||
<span
|
||||
:title="`Input: ${usage.inputTokens.toLocaleString()}, Output: ${usage.outputTokens.toLocaleString()}`"
|
||||
>
|
||||
{{ formatTokens(usage.inputTokens + usage.outputTokens) }} tokens
|
||||
</span>
|
||||
<span class="opacity-50">·</span>
|
||||
<span
|
||||
:title="`Duration: ${(usage.durationMs / 1000).toFixed(1)}s, Turns: ${usage.numTurns}`"
|
||||
>
|
||||
${{ usage.totalCostUsd.toFixed(4) }}
|
||||
</span>
|
||||
<span class="opacity-50">·</span>
|
||||
</template>
|
||||
<span>Claude Code Preview</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import type { AgentUsageStats } from 'chrome-mcp-shared';
|
||||
|
||||
defineProps<{
|
||||
errorMessage?: string | null;
|
||||
usage?: AgentUsageStats | null;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* Format token count for display (e.g., 1.2k, 3.5M)
|
||||
*/
|
||||
function formatTokens(count: number): string {
|
||||
if (count >= 1_000_000) {
|
||||
return (count / 1_000_000).toFixed(1) + 'M';
|
||||
}
|
||||
if (count >= 1_000) {
|
||||
return (count / 1_000).toFixed(1) + 'k';
|
||||
}
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
const shellRef = ref<HTMLElement | null>(null);
|
||||
const contentRef = ref<HTMLElement | null>(null);
|
||||
const contentSlotRef = ref<HTMLElement | null>(null);
|
||||
|
||||
+97
-12
@@ -103,22 +103,48 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Model Input -->
|
||||
<!-- Model Selection -->
|
||||
<div class="px-3 py-2 flex items-center gap-2">
|
||||
<span class="text-xs w-12" :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"> Model </span>
|
||||
<input
|
||||
:value="model"
|
||||
type="text"
|
||||
placeholder="e.g. claude-3-opus"
|
||||
class="flex-1 px-2 py-1 text-xs rounded outline-none focus:ring-1 focus:ring-blue-500"
|
||||
<select
|
||||
:value="normalizedModel"
|
||||
class="flex-1 px-2 py-1 text-xs rounded"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-surface-muted, #f2f0eb)',
|
||||
border: 'var(--ac-border-width, 1px) solid var(--ac-border, #e5e5e5)',
|
||||
color: 'var(--ac-text, #1a1a1a)',
|
||||
borderRadius: 'var(--ac-radius-button, 8px)',
|
||||
}"
|
||||
@input="handleModelInput"
|
||||
/>
|
||||
:disabled="isModelDisabled"
|
||||
@change="handleModelChange"
|
||||
>
|
||||
<option value="">Default</option>
|
||||
<option v-for="m in availableModels" :key="m.id" :value="m.id">
|
||||
{{ m.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- CCR Option (Claude Code Router) - only shown when Claude CLI is selected -->
|
||||
<div v-if="showCcrOption" class="px-3 py-2 flex items-center gap-2">
|
||||
<span class="text-xs w-12" :style="{ color: 'var(--ac-text-muted, #6e6e6e)' }"> CCR </span>
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
title="Use Claude Code Router for API routing"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="useCcr"
|
||||
class="w-4 h-4 rounded"
|
||||
:style="{
|
||||
accentColor: 'var(--ac-accent, #c87941)',
|
||||
}"
|
||||
@change="handleCcrChange"
|
||||
/>
|
||||
<span class="text-xs" :style="{ color: 'var(--ac-text, #1a1a1a)' }">
|
||||
Use Claude Code Router
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Root Override -->
|
||||
@@ -164,14 +190,21 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import type { AgentProject, AgentEngineInfo } from 'chrome-mcp-shared';
|
||||
import {
|
||||
getModelsForCli,
|
||||
getDefaultModelForCli,
|
||||
type ModelDefinition,
|
||||
} from '@/common/agent-models';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
projects: AgentProject[];
|
||||
selectedProjectId: string;
|
||||
selectedCli: string;
|
||||
model: string;
|
||||
useCcr: boolean;
|
||||
projectRootOverride: string;
|
||||
engines: AgentEngineInfo[];
|
||||
isPicking: boolean;
|
||||
@@ -184,16 +217,68 @@ const emit = defineEmits<{
|
||||
'project:new': [];
|
||||
'cli:update': [cli: string];
|
||||
'model:update': [model: string];
|
||||
'ccr:update': [useCcr: boolean];
|
||||
'root:update': [root: string];
|
||||
save: [];
|
||||
}>();
|
||||
|
||||
// Get available models based on selected CLI
|
||||
const availableModels = computed<ModelDefinition[]>(() => {
|
||||
return getModelsForCli(props.selectedCli);
|
||||
});
|
||||
|
||||
// Normalize model value: ensure it exists in available models or fallback to empty
|
||||
const normalizedModel = computed(() => {
|
||||
const trimmedModel = props.model.trim();
|
||||
if (!trimmedModel) return '';
|
||||
// No CLI selected = model disabled, show empty (server will use default)
|
||||
if (!props.selectedCli) return '';
|
||||
const models = availableModels.value;
|
||||
// If CLI selected but no models defined, fallback to empty
|
||||
if (models.length === 0) return '';
|
||||
// Check if current model is valid for selected CLI
|
||||
const isValid = models.some((m) => m.id === trimmedModel);
|
||||
return isValid ? trimmedModel : '';
|
||||
});
|
||||
|
||||
// Check if Model select should be disabled
|
||||
const isModelDisabled = computed(() => {
|
||||
return !props.selectedCli || availableModels.value.length === 0;
|
||||
});
|
||||
|
||||
// Show CCR option only when Claude CLI is selected
|
||||
const showCcrOption = computed(() => {
|
||||
return props.selectedCli === 'claude';
|
||||
});
|
||||
|
||||
// Handle CLI change - auto-select default model for the CLI
|
||||
function handleCliChange(event: Event): void {
|
||||
emit('cli:update', (event.target as HTMLSelectElement).value);
|
||||
const cli = (event.target as HTMLSelectElement).value;
|
||||
emit('cli:update', cli);
|
||||
|
||||
// Auto-select default model when CLI changes
|
||||
if (cli) {
|
||||
const defaultModel = getDefaultModelForCli(cli);
|
||||
// Validate default model exists in available models
|
||||
const models = getModelsForCli(cli);
|
||||
const isValidDefault = models.some((m) => m.id === defaultModel);
|
||||
emit('model:update', isValidDefault ? defaultModel : (models[0]?.id ?? ''));
|
||||
} else {
|
||||
emit('model:update', '');
|
||||
}
|
||||
|
||||
// Reset CCR when switching away from Claude
|
||||
if (cli !== 'claude') {
|
||||
emit('ccr:update', false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleModelInput(event: Event): void {
|
||||
emit('model:update', (event.target as HTMLInputElement).value);
|
||||
function handleCcrChange(event: Event): void {
|
||||
emit('ccr:update', (event.target as HTMLInputElement).checked);
|
||||
}
|
||||
|
||||
function handleModelChange(event: Event): void {
|
||||
emit('model:update', (event.target as HTMLSelectElement).value);
|
||||
}
|
||||
|
||||
function handleRootInput(event: Event): void {
|
||||
|
||||
+98
-4
@@ -1,14 +1,19 @@
|
||||
<template>
|
||||
<div class="py-1">
|
||||
<p
|
||||
class="text-sm leading-relaxed"
|
||||
<div
|
||||
class="text-sm leading-relaxed markdown-content"
|
||||
:style="{
|
||||
color: 'var(--ac-text)',
|
||||
fontFamily: 'var(--ac-font-body)',
|
||||
}"
|
||||
>
|
||||
{{ item.text }}
|
||||
</p>
|
||||
<MarkdownRender
|
||||
:content="item.text ?? ''"
|
||||
:max-live-nodes="0"
|
||||
:render-batch-size="16"
|
||||
:render-batch-delay="8"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="item.isStreaming"
|
||||
class="inline-block w-1.5 h-4 ml-0.5 ac-pulse"
|
||||
@@ -19,8 +24,97 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { TimelineItem } from '../../../composables/useAgentThreads';
|
||||
import MarkdownRender from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
|
||||
defineProps<{
|
||||
item: Extract<TimelineItem, { kind: 'assistant_text' }>;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-content :deep(pre) {
|
||||
background-color: var(--ac-code-bg);
|
||||
border: 1px solid var(--ac-code-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code) {
|
||||
font-family: var(--ac-font-mono);
|
||||
font-size: 0.875em;
|
||||
color: var(--ac-code-text);
|
||||
}
|
||||
|
||||
.markdown-content :deep(p) {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(p:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.markdown-content :deep(h2),
|
||||
.markdown-content :deep(h3),
|
||||
.markdown-content :deep(h4) {
|
||||
margin: 0.75em 0 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown-content :deep(blockquote) {
|
||||
border-left: 3px solid var(--ac-border);
|
||||
padding-left: 1em;
|
||||
margin: 0.5em 0;
|
||||
color: var(--ac-text-muted);
|
||||
}
|
||||
|
||||
.markdown-content :deep(a) {
|
||||
color: var(--ac-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(a:hover) {
|
||||
color: var(--ac-link-hover);
|
||||
}
|
||||
|
||||
.markdown-content :deep(table) {
|
||||
border-collapse: collapse;
|
||||
margin: 0.5em 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td) {
|
||||
border: 1px solid var(--ac-border);
|
||||
padding: 0.5em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th) {
|
||||
background-color: var(--ac-surface-muted);
|
||||
}
|
||||
|
||||
.markdown-content :deep(hr) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--ac-border);
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(img) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+108
-38
@@ -1,47 +1,80 @@
|
||||
<template>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<!-- Label -->
|
||||
<span
|
||||
class="text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0"
|
||||
:style="{
|
||||
color: labelColor,
|
||||
}"
|
||||
>
|
||||
{{ item.tool.label }}
|
||||
</span>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<!-- Label -->
|
||||
<span
|
||||
class="text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0"
|
||||
:style="{
|
||||
color: labelColor,
|
||||
}"
|
||||
>
|
||||
{{ item.tool.label }}
|
||||
</span>
|
||||
|
||||
<!-- Content based on tool kind -->
|
||||
<code
|
||||
v-if="item.tool.kind === 'grep' || item.tool.kind === 'read'"
|
||||
class="text-xs px-1.5 py-0.5 rounded cursor-pointer ac-chip-hover"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
backgroundColor: 'var(--ac-chip-bg)',
|
||||
color: 'var(--ac-chip-text)',
|
||||
}"
|
||||
>
|
||||
{{ item.tool.title }}
|
||||
</code>
|
||||
<!-- Content based on tool kind -->
|
||||
<code
|
||||
v-if="item.tool.kind === 'grep' || item.tool.kind === 'read'"
|
||||
class="text-xs px-1.5 py-0.5 rounded cursor-pointer ac-chip-hover"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
backgroundColor: 'var(--ac-chip-bg)',
|
||||
color: 'var(--ac-chip-text)',
|
||||
}"
|
||||
:title="item.tool.filePath || item.tool.pattern"
|
||||
>
|
||||
{{ item.tool.title }}
|
||||
</code>
|
||||
|
||||
<span
|
||||
v-else
|
||||
class="text-xs"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
color: 'var(--ac-text-muted)',
|
||||
}"
|
||||
>
|
||||
{{ item.tool.title }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-xs"
|
||||
:style="{
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
color: 'var(--ac-text-muted)',
|
||||
}"
|
||||
:title="item.tool.filePath || item.tool.command"
|
||||
>
|
||||
{{ item.tool.title }}
|
||||
</span>
|
||||
|
||||
<!-- Streaming indicator -->
|
||||
<span
|
||||
v-if="item.isStreaming"
|
||||
class="text-xs italic"
|
||||
<!-- Diff Stats Preview (for edit) -->
|
||||
<span
|
||||
v-if="hasDiffStats"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-chip-bg)',
|
||||
color: 'var(--ac-text-muted)',
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
}"
|
||||
>
|
||||
<span v-if="item.tool.diffStats?.addedLines" class="text-green-600 dark:text-green-400">
|
||||
+{{ item.tool.diffStats.addedLines }}
|
||||
</span>
|
||||
<span v-if="item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines">/</span>
|
||||
<span v-if="item.tool.diffStats?.deletedLines" class="text-red-600 dark:text-red-400">
|
||||
-{{ item.tool.diffStats.deletedLines }}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<!-- Streaming indicator -->
|
||||
<span
|
||||
v-if="item.isStreaming"
|
||||
class="text-xs italic"
|
||||
:style="{ color: 'var(--ac-text-subtle)' }"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Subtitle (command description or search path) -->
|
||||
<div
|
||||
v-if="subtitle"
|
||||
class="text-[10px] pl-10 truncate"
|
||||
:style="{ color: 'var(--ac-text-subtle)' }"
|
||||
:title="subtitleFull"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
{{ subtitle }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -59,4 +92,41 @@ const labelColor = computed(() => {
|
||||
}
|
||||
return 'var(--ac-text-subtle)';
|
||||
});
|
||||
|
||||
const hasDiffStats = computed(() => {
|
||||
const stats = props.item.tool.diffStats;
|
||||
if (!stats) return false;
|
||||
return stats.addedLines !== undefined || stats.deletedLines !== undefined;
|
||||
});
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const tool = props.item.tool;
|
||||
|
||||
// For commands: show the actual command if title is description
|
||||
if (tool.kind === 'run' && tool.commandDescription && tool.command) {
|
||||
return tool.command.length > 60 ? tool.command.slice(0, 57) + '...' : tool.command;
|
||||
}
|
||||
|
||||
// For file operations: show full path if title is just filename
|
||||
if ((tool.kind === 'edit' || tool.kind === 'read') && tool.filePath) {
|
||||
if (tool.filePath !== tool.title && !tool.title.includes('/')) {
|
||||
return tool.filePath;
|
||||
}
|
||||
}
|
||||
|
||||
// For search: show search path if provided
|
||||
if (tool.kind === 'grep' && tool.searchPath) {
|
||||
return `in ${tool.searchPath}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const subtitleFull = computed(() => {
|
||||
const tool = props.item.tool;
|
||||
if (tool.kind === 'run' && tool.command) return tool.command;
|
||||
if (tool.filePath) return tool.filePath;
|
||||
if (tool.searchPath) return tool.searchPath;
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
+57
-2
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<!-- Label + Title -->
|
||||
<div class="flex items-baseline gap-2">
|
||||
<!-- Label + Title + Diff Stats -->
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<span
|
||||
class="text-[11px] font-bold uppercase tracking-wider w-8 flex-shrink-0"
|
||||
:style="{ color: labelColor }"
|
||||
@@ -14,9 +14,47 @@
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
color: 'var(--ac-text)',
|
||||
}"
|
||||
:title="item.tool.filePath"
|
||||
>
|
||||
{{ item.tool.title }}
|
||||
</code>
|
||||
<!-- Diff Stats Badge -->
|
||||
<span
|
||||
v-if="hasDiffStats"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:style="{
|
||||
backgroundColor: 'var(--ac-chip-bg)',
|
||||
color: 'var(--ac-text-muted)',
|
||||
fontFamily: 'var(--ac-font-mono)',
|
||||
}"
|
||||
>
|
||||
<span v-if="item.tool.diffStats?.addedLines" class="text-green-600 dark:text-green-400">
|
||||
+{{ item.tool.diffStats.addedLines }}
|
||||
</span>
|
||||
<span v-if="item.tool.diffStats?.addedLines && item.tool.diffStats?.deletedLines">/</span>
|
||||
<span v-if="item.tool.diffStats?.deletedLines" class="text-red-600 dark:text-red-400">
|
||||
-{{ item.tool.diffStats.deletedLines }}
|
||||
</span>
|
||||
<span
|
||||
v-if="
|
||||
!item.tool.diffStats?.addedLines &&
|
||||
!item.tool.diffStats?.deletedLines &&
|
||||
item.tool.diffStats?.totalLines
|
||||
"
|
||||
>
|
||||
{{ item.tool.diffStats.totalLines }} lines
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- File Path (if different from title) -->
|
||||
<div
|
||||
v-if="showFilePath"
|
||||
class="text-[10px] pl-10 truncate"
|
||||
:style="{ color: 'var(--ac-text-subtle)' }"
|
||||
:title="item.tool.filePath"
|
||||
>
|
||||
{{ item.tool.filePath }}
|
||||
</div>
|
||||
|
||||
<!-- Result Card -->
|
||||
@@ -134,6 +172,23 @@ const labelColor = computed(() => {
|
||||
return 'var(--ac-success)';
|
||||
});
|
||||
|
||||
const hasDiffStats = computed(() => {
|
||||
const stats = props.item.tool.diffStats;
|
||||
if (!stats) return false;
|
||||
return (
|
||||
stats.addedLines !== undefined ||
|
||||
stats.deletedLines !== undefined ||
|
||||
stats.totalLines !== undefined
|
||||
);
|
||||
});
|
||||
|
||||
const showFilePath = computed(() => {
|
||||
const tool = props.item.tool;
|
||||
// Show full path if title is just the filename
|
||||
if (!tool.filePath) return false;
|
||||
return tool.filePath !== tool.title && !tool.title.includes('/');
|
||||
});
|
||||
|
||||
const showCard = computed(() => {
|
||||
const tool = props.item.tool;
|
||||
return (
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
RealtimeEvent,
|
||||
AgentStatusEvent,
|
||||
AgentCliPreference,
|
||||
AgentUsageStats,
|
||||
} from 'chrome-mcp-shared';
|
||||
|
||||
export interface UseAgentChatOptions {
|
||||
@@ -29,6 +30,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
const currentRequestId = ref<string | null>(null);
|
||||
const cancelling = ref(false);
|
||||
const attachments = ref<AgentAttachment[]>([]);
|
||||
const lastUsage = ref<AgentUsageStats | null>(null);
|
||||
|
||||
// Computed
|
||||
const canSend = computed(() => {
|
||||
@@ -54,6 +56,9 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
case 'heartbeat':
|
||||
// Heartbeat received, connection is alive
|
||||
break;
|
||||
case 'usage':
|
||||
lastUsage.value = event.data;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,6 +239,7 @@ export function useAgentChat(options: UseAgentChatOptions) {
|
||||
currentRequestId,
|
||||
cancelling,
|
||||
attachments,
|
||||
lastUsage,
|
||||
|
||||
// Computed
|
||||
canSend,
|
||||
|
||||
@@ -20,6 +20,13 @@ export type ToolKind = 'grep' | 'read' | 'edit' | 'run' | 'plan' | 'generic';
|
||||
/** Tool severity for styling */
|
||||
export type ToolSeverity = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
/** Diff statistics for edit operations */
|
||||
export interface DiffStats {
|
||||
addedLines?: number;
|
||||
deletedLines?: number;
|
||||
totalLines?: number;
|
||||
}
|
||||
|
||||
/** Structured tool presentation */
|
||||
export interface ToolPresentation {
|
||||
kind: ToolKind;
|
||||
@@ -28,8 +35,18 @@ export interface ToolPresentation {
|
||||
subtitle?: string;
|
||||
details?: string;
|
||||
files?: string[];
|
||||
/** File path for single-file operations */
|
||||
filePath?: string;
|
||||
/** Diff statistics for edit/write operations */
|
||||
diffStats?: DiffStats;
|
||||
command?: string;
|
||||
/** Command description from bash tool */
|
||||
commandDescription?: string;
|
||||
query?: string;
|
||||
/** Search pattern for grep/glob */
|
||||
pattern?: string;
|
||||
/** Search path */
|
||||
searchPath?: string;
|
||||
engine?: string;
|
||||
severity: ToolSeverity;
|
||||
phase: 'use' | 'result';
|
||||
@@ -134,6 +151,27 @@ function titleCase(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file name from path
|
||||
*/
|
||||
function getFileName(filePath: string): string {
|
||||
return filePath.split('/').pop() || filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build diff stats from metadata
|
||||
*/
|
||||
function buildDiffStats(meta: Record<string, unknown>): DiffStats | undefined {
|
||||
const addedLines = typeof meta.addedLines === 'number' ? meta.addedLines : undefined;
|
||||
const deletedLines = typeof meta.deletedLines === 'number' ? meta.deletedLines : undefined;
|
||||
const totalLines = typeof meta.totalLines === 'number' ? meta.totalLines : undefined;
|
||||
|
||||
if (addedLines !== undefined || deletedLines !== undefined || totalLines !== undefined) {
|
||||
return { addedLines, deletedLines, totalLines };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Present a tool message as ToolPresentation
|
||||
*/
|
||||
@@ -152,12 +190,26 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
meta.isError === true ||
|
||||
(typeof msg.content === 'string' && msg.content.trimStart().startsWith('Error:'));
|
||||
|
||||
// Rule 1: Plan (Codex todo_list)
|
||||
if (meta.planPhase || normalize(toolName) === 'plan') {
|
||||
// Extract common metadata fields
|
||||
const filePath = firstString(meta.filePath as string);
|
||||
const command = firstString(meta.command as string);
|
||||
const commandDescription = firstString(meta.commandDescription as string);
|
||||
const pattern = firstString(meta.pattern as string);
|
||||
const searchPath = firstString(meta.searchPath as string);
|
||||
const diffStats = buildDiffStats(meta);
|
||||
|
||||
// Rule 1: Plan / TodoWrite
|
||||
if (
|
||||
meta.planPhase ||
|
||||
normalize(toolName) === 'plan' ||
|
||||
normalize(toolName) === 'todo_write' ||
|
||||
normalize(toolName) === 'todowrite'
|
||||
) {
|
||||
const todoCount = typeof meta.todoCount === 'number' ? meta.todoCount : undefined;
|
||||
return {
|
||||
kind: 'plan',
|
||||
label: 'Plan',
|
||||
title: summarizeOneLine(msg.content) || 'Plan update',
|
||||
title: todoCount ? `${todoCount} tasks` : summarizeOneLine(msg.content) || 'Plan update',
|
||||
details: phase === 'result' ? msg.content : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : 'info',
|
||||
@@ -166,18 +218,19 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 2: File summary (Codex file_change -> metadata.files)
|
||||
const files = Array.isArray(meta.files)
|
||||
? (meta.files as string[]).filter((x) => typeof x === 'string')
|
||||
: [];
|
||||
if (files.length > 0) {
|
||||
const title = files.length === 1 ? files[0] : `${files.length} files`;
|
||||
// Rule 2: Edit tool with file path and diff stats
|
||||
if (
|
||||
normalize(toolName).includes('edit') ||
|
||||
normalize(toolName) === 'apply_patch' ||
|
||||
normalize(toolName) === 'patch_file'
|
||||
) {
|
||||
const fileName = filePath ? getFileName(filePath) : undefined;
|
||||
return {
|
||||
kind: 'edit',
|
||||
label: 'Edit',
|
||||
title,
|
||||
subtitle: files.length > 1 ? files.slice(0, 3).join(', ') : undefined,
|
||||
files,
|
||||
title: fileName || filePath || 'File',
|
||||
filePath,
|
||||
diffStats,
|
||||
details: phase === 'result' ? msg.content : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : 'success',
|
||||
@@ -186,8 +239,46 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 3: Command (Codex bash, or Claude "Running:")
|
||||
const command = firstString(meta.command as string);
|
||||
// Rule 3: Write/Create tool
|
||||
if (normalize(toolName).includes('write') || normalize(toolName) === 'create_file') {
|
||||
const fileName = filePath ? getFileName(filePath) : undefined;
|
||||
return {
|
||||
kind: 'edit',
|
||||
label: 'Write',
|
||||
title: fileName || filePath || 'File',
|
||||
filePath,
|
||||
diffStats,
|
||||
details: phase === 'result' ? msg.content : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : 'success',
|
||||
phase,
|
||||
raw: { content: msg.content, metadata: meta },
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 4: File summary (Codex file_change -> metadata.files)
|
||||
const files = Array.isArray(meta.files)
|
||||
? (meta.files as string[]).filter((x) => typeof x === 'string')
|
||||
: [];
|
||||
if (files.length > 0) {
|
||||
const title = files.length === 1 ? getFileName(files[0]) : `${files.length} files`;
|
||||
return {
|
||||
kind: 'edit',
|
||||
label: 'Edit',
|
||||
title,
|
||||
subtitle: files.length > 1 ? files.slice(0, 3).map(getFileName).join(', ') : undefined,
|
||||
files,
|
||||
filePath: files.length === 1 ? files[0] : undefined,
|
||||
diffStats,
|
||||
details: phase === 'result' ? msg.content : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : 'success',
|
||||
phase,
|
||||
raw: { content: msg.content, metadata: meta },
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 5: Command (Bash/shell)
|
||||
if (
|
||||
normalize(toolName) === 'bash' ||
|
||||
normalize(toolName).includes('shell') ||
|
||||
@@ -207,8 +298,10 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
return {
|
||||
kind: 'run',
|
||||
label: 'Run',
|
||||
title: extractedCommand?.trim() || 'Command',
|
||||
title: commandDescription || extractedCommand?.trim() || 'Command',
|
||||
subtitle: commandDescription && extractedCommand ? extractedCommand.trim() : undefined,
|
||||
command: extractedCommand?.trim(),
|
||||
commandDescription,
|
||||
details,
|
||||
engine,
|
||||
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
|
||||
@@ -217,14 +310,18 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 4: Search / Grep (Claude "Searching:")
|
||||
const queryFromContent = extractAfterPrefix(msg.content, 'Searching:');
|
||||
if (queryFromContent) {
|
||||
// Rule 6: Grep/Search with pattern
|
||||
if (normalize(toolName) === 'grep' || normalize(toolName).includes('search') || pattern) {
|
||||
const queryFromContent = extractAfterPrefix(msg.content, 'Searching:');
|
||||
const displayPattern = pattern || queryFromContent?.trim();
|
||||
return {
|
||||
kind: 'grep',
|
||||
label: 'Grep',
|
||||
title: queryFromContent.trim(),
|
||||
query: queryFromContent.trim(),
|
||||
title: displayPattern || 'Search',
|
||||
pattern: displayPattern,
|
||||
searchPath,
|
||||
query: displayPattern,
|
||||
details: phase === 'result' ? msg.content : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : 'info',
|
||||
phase,
|
||||
@@ -232,7 +329,38 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 5: Read / Edit by action
|
||||
// Rule 7: Glob with pattern
|
||||
if (normalize(toolName) === 'glob' || normalize(toolName) === 'glob_files') {
|
||||
return {
|
||||
kind: 'grep',
|
||||
label: 'Glob',
|
||||
title: pattern || 'Pattern search',
|
||||
pattern,
|
||||
searchPath,
|
||||
details: phase === 'result' ? msg.content : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : 'info',
|
||||
phase,
|
||||
raw: { content: msg.content, metadata: meta },
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 8: Read tool
|
||||
if (normalize(toolName).includes('read') || filePath) {
|
||||
const fileName = filePath ? getFileName(filePath) : undefined;
|
||||
return {
|
||||
kind: 'read',
|
||||
label: 'Read',
|
||||
title: fileName || filePath || 'File',
|
||||
filePath,
|
||||
engine,
|
||||
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
|
||||
phase,
|
||||
raw: { content: msg.content, metadata: meta },
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 9: Read / Edit by action (fallback for content-based detection)
|
||||
const action = firstString(meta.action as string);
|
||||
const fileFromContent = extractAfterPrefix(msg.content, 'Operating on:')?.trim();
|
||||
const inferredKind =
|
||||
@@ -247,7 +375,9 @@ function presentTool(msg: AgentMessage): ToolPresentation {
|
||||
return {
|
||||
kind,
|
||||
label: kind === 'read' ? 'Read' : 'Edit',
|
||||
title: fileFromContent || toolName,
|
||||
title: fileFromContent ? getFileName(fileFromContent) : toolName,
|
||||
filePath: fileFromContent,
|
||||
diffStats: kind === 'edit' ? diffStats : undefined,
|
||||
engine,
|
||||
severity: isError ? 'error' : phase === 'result' ? 'success' : 'info',
|
||||
phase,
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"date-fns": "^4.1.0",
|
||||
"elkjs": "^0.11.0",
|
||||
"hnswlib-wasm-static": "0.8.5",
|
||||
"markstream-vue": "0.0.3-beta.5",
|
||||
"vue": "^3.5.13",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
|
||||
@@ -105,15 +105,35 @@ export class ClaudeEngine implements AgentEngine {
|
||||
let assistantBuffer = '';
|
||||
let assistantMessageId: string | null = null;
|
||||
let assistantCreatedAt: string | null = null;
|
||||
let lastAssistantEmitted: { content: string; isFinal: boolean } | null = null;
|
||||
const streamedToolHashes = new Set<string>();
|
||||
|
||||
// Tool input accumulation for streaming tool_use blocks
|
||||
// Key: content block index, Value: { toolName, toolId, inputJson }
|
||||
const pendingToolInputs = new Map<
|
||||
number,
|
||||
{ toolName: string; toolId: string; inputJsonParts: string[] }
|
||||
>();
|
||||
let currentContentBlockIndex = -1;
|
||||
|
||||
/**
|
||||
* Emit assistant message to the stream.
|
||||
* Includes deduplication to prevent multiple identical final emissions.
|
||||
*/
|
||||
const emitAssistant = (isFinal: boolean): void => {
|
||||
const content = assistantBuffer.trim();
|
||||
if (!content) return;
|
||||
|
||||
// Deduplicate: skip if same content and isFinal state was already emitted
|
||||
if (
|
||||
lastAssistantEmitted &&
|
||||
lastAssistantEmitted.content === content &&
|
||||
lastAssistantEmitted.isFinal === isFinal
|
||||
) {
|
||||
return;
|
||||
}
|
||||
lastAssistantEmitted = { content, isFinal };
|
||||
|
||||
if (!assistantMessageId) {
|
||||
assistantMessageId = randomUUID();
|
||||
}
|
||||
@@ -230,7 +250,7 @@ export class ClaudeEngine implements AgentEngine {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build tool metadata from content block.
|
||||
* Build tool metadata from content block with detailed tool-specific information.
|
||||
*/
|
||||
const buildToolMetadata = (contentBlock: Record<string, unknown>): Record<string, unknown> => {
|
||||
const toolName = this.pickFirstString(contentBlock.name) || 'unknown';
|
||||
@@ -238,13 +258,97 @@ export class ClaudeEngine implements AgentEngine {
|
||||
const input = contentBlock.input as Record<string, unknown> | undefined;
|
||||
const action = inferActionFromToolName(toolName);
|
||||
|
||||
return {
|
||||
const metadata: Record<string, unknown> = {
|
||||
toolName,
|
||||
tool_name: toolName,
|
||||
toolId,
|
||||
action,
|
||||
input: input ? JSON.stringify(input).slice(0, 500) : undefined,
|
||||
};
|
||||
|
||||
if (!input) {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// Extract tool-specific details
|
||||
const normalizedName = toolName.toLowerCase();
|
||||
|
||||
// File operations (read, write, edit)
|
||||
if (typeof input.file_path === 'string') {
|
||||
metadata.filePath = input.file_path;
|
||||
}
|
||||
|
||||
// Edit tool - extract diff information
|
||||
if (
|
||||
normalizedName.includes('edit') ||
|
||||
normalizedName === 'apply_patch' ||
|
||||
normalizedName === 'patch_file'
|
||||
) {
|
||||
if (typeof input.old_string === 'string') {
|
||||
metadata.oldString = input.old_string;
|
||||
metadata.deletedLines = input.old_string.split('\n').length;
|
||||
}
|
||||
if (typeof input.new_string === 'string') {
|
||||
metadata.newString = input.new_string;
|
||||
metadata.addedLines = input.new_string.split('\n').length;
|
||||
}
|
||||
if (typeof input.replace_all === 'boolean') {
|
||||
metadata.replaceAll = input.replace_all;
|
||||
}
|
||||
}
|
||||
|
||||
// Write tool - content preview
|
||||
if (normalizedName.includes('write') || normalizedName === 'create_file') {
|
||||
if (typeof input.content === 'string') {
|
||||
metadata.contentPreview = input.content.slice(0, 200);
|
||||
metadata.totalLines = input.content.split('\n').length;
|
||||
}
|
||||
}
|
||||
|
||||
// Read tool - offset/limit
|
||||
if (normalizedName.includes('read')) {
|
||||
if (typeof input.offset === 'number') metadata.offset = input.offset;
|
||||
if (typeof input.limit === 'number') metadata.limit = input.limit;
|
||||
}
|
||||
|
||||
// Bash/shell - command
|
||||
if (
|
||||
normalizedName === 'bash' ||
|
||||
normalizedName.includes('shell') ||
|
||||
normalizedName === 'run'
|
||||
) {
|
||||
if (typeof input.command === 'string') {
|
||||
metadata.command = input.command;
|
||||
}
|
||||
if (typeof input.description === 'string') {
|
||||
metadata.commandDescription = input.description;
|
||||
}
|
||||
}
|
||||
|
||||
// Search tools (grep, glob)
|
||||
if (normalizedName === 'grep' || normalizedName.includes('search')) {
|
||||
if (typeof input.pattern === 'string') metadata.pattern = input.pattern;
|
||||
if (typeof input.path === 'string') metadata.searchPath = input.path;
|
||||
if (typeof input.glob === 'string') metadata.glob = input.glob;
|
||||
if (typeof input.output_mode === 'string') metadata.outputMode = input.output_mode;
|
||||
}
|
||||
|
||||
if (normalizedName === 'glob' || normalizedName === 'glob_files') {
|
||||
if (typeof input.pattern === 'string') metadata.pattern = input.pattern;
|
||||
if (typeof input.path === 'string') metadata.searchPath = input.path;
|
||||
}
|
||||
|
||||
// TodoWrite
|
||||
if (normalizedName === 'todo_write' || normalizedName === 'todowrite') {
|
||||
if (Array.isArray(input.todos)) {
|
||||
metadata.todoCount = input.todos.length;
|
||||
metadata.todos = input.todos;
|
||||
}
|
||||
}
|
||||
|
||||
// Store raw input for debugging (truncated)
|
||||
metadata.rawInput = JSON.stringify(input).slice(0, 1000);
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -290,6 +394,9 @@ export class ClaudeEngine implements AgentEngine {
|
||||
// Both permissionMode and allowDangerouslySkipPermissions are required for auto-approval
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
// Enable streaming: emit stream_event with content_block_delta for real-time UI updates
|
||||
// Without this, SDK only outputs aggregated assistant/result messages
|
||||
includePartialMessages: true,
|
||||
images: imageFiles.length > 0 ? imageFiles : undefined,
|
||||
executable: process.execPath,
|
||||
// Pass merged env to support Claude Code Router (CCR)
|
||||
@@ -339,25 +446,27 @@ export class ClaudeEngine implements AgentEngine {
|
||||
assistantBuffer = '';
|
||||
assistantMessageId = randomUUID();
|
||||
assistantCreatedAt = new Date().toISOString();
|
||||
lastAssistantEmitted = null;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'content_block_start': {
|
||||
const contentBlock = event.content_block as Record<string, unknown> | undefined;
|
||||
const blockIndex =
|
||||
typeof event.index === 'number' ? event.index : ++currentContentBlockIndex;
|
||||
currentContentBlockIndex = blockIndex;
|
||||
|
||||
if (contentBlock && contentBlock.type === 'tool_use') {
|
||||
const metadata = buildToolMetadata(contentBlock);
|
||||
const toolName = this.pickFirstString(contentBlock.name) || 'tool';
|
||||
const input = contentBlock.input as Record<string, unknown> | undefined;
|
||||
const toolId = this.pickFirstString(contentBlock.id) || '';
|
||||
|
||||
// Build more informative content based on tool input
|
||||
let content = `Using tool: ${toolName}`;
|
||||
if (input) {
|
||||
if (input.command) content = `Running: ${input.command}`;
|
||||
else if (input.file_path) content = `Operating on: ${input.file_path}`;
|
||||
else if (input.query) content = `Searching: ${input.query}`;
|
||||
}
|
||||
|
||||
dispatchToolMessage(content, metadata, 'tool_use', true);
|
||||
// Store pending tool input for accumulation
|
||||
// Don't emit message here - wait for content_block_stop with complete input
|
||||
pendingToolInputs.set(blockIndex, {
|
||||
toolName,
|
||||
toolId,
|
||||
inputJsonParts: [],
|
||||
});
|
||||
} else if (contentBlock && contentBlock.type === 'tool_result') {
|
||||
// Handle tool_result in content_block_start
|
||||
const metadata = this.buildToolResultMetadata(contentBlock);
|
||||
@@ -377,6 +486,47 @@ export class ClaudeEngine implements AgentEngine {
|
||||
}
|
||||
|
||||
case 'content_block_stop': {
|
||||
const blockIndex =
|
||||
typeof event.index === 'number' ? event.index : currentContentBlockIndex;
|
||||
|
||||
// Check if we have accumulated tool input for this block
|
||||
if (pendingToolInputs.has(blockIndex)) {
|
||||
const pending = pendingToolInputs.get(blockIndex)!;
|
||||
pendingToolInputs.delete(blockIndex);
|
||||
|
||||
// Parse the accumulated JSON
|
||||
const fullJsonStr = pending.inputJsonParts.join('');
|
||||
let input: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fullJsonStr) {
|
||||
input = JSON.parse(fullJsonStr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[ClaudeEngine] Failed to parse tool input JSON: ${e}`);
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[ClaudeEngine] content_block_stop - toolName: ${pending.toolName}, input: ${JSON.stringify(input).slice(0, 500)}`,
|
||||
);
|
||||
|
||||
// Build metadata with full input
|
||||
const metadata = buildToolMetadata({
|
||||
name: pending.toolName,
|
||||
id: pending.toolId,
|
||||
input,
|
||||
});
|
||||
|
||||
// Build informative content
|
||||
let content = `Using tool: ${pending.toolName}`;
|
||||
if (input.command) content = `Running: ${input.command}`;
|
||||
else if (input.file_path) content = `Operating on: ${input.file_path}`;
|
||||
else if (input.pattern) content = `Searching: ${input.pattern}`;
|
||||
else if (input.query) content = `Searching: ${input.query}`;
|
||||
|
||||
// Emit final tool_use message with complete metadata
|
||||
dispatchToolMessage(content, metadata, 'tool_use', false);
|
||||
}
|
||||
|
||||
// Check if this block was a tool_result
|
||||
const contentBlock = event.content_block as Record<string, unknown> | undefined;
|
||||
if (contentBlock && contentBlock.type === 'tool_result') {
|
||||
@@ -398,6 +548,19 @@ export class ClaudeEngine implements AgentEngine {
|
||||
|
||||
case 'content_block_delta': {
|
||||
const delta = event.delta as Record<string, unknown> | string | undefined;
|
||||
const blockIndex =
|
||||
typeof event.index === 'number' ? event.index : currentContentBlockIndex;
|
||||
|
||||
// Check if this is a tool_use input_json_delta
|
||||
if (delta && typeof delta === 'object' && delta.type === 'input_json_delta') {
|
||||
const partialJson = delta.partial_json as string | undefined;
|
||||
if (partialJson && pendingToolInputs.has(blockIndex)) {
|
||||
pendingToolInputs.get(blockIndex)!.inputJsonParts.push(partialJson);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle text delta for assistant messages
|
||||
let textChunk = '';
|
||||
|
||||
if (typeof delta === 'string') {
|
||||
@@ -419,12 +582,15 @@ export class ClaudeEngine implements AgentEngine {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message_stop':
|
||||
case 'message_delta': {
|
||||
// Emit final assistant message
|
||||
if (assistantBuffer.trim()) {
|
||||
emitAssistant(true);
|
||||
}
|
||||
// message_delta usually contains metadata only (stop_reason, usage)
|
||||
// Don't emit final here to avoid duplicate finals
|
||||
break;
|
||||
}
|
||||
|
||||
case 'message_stop': {
|
||||
// Emit final assistant message only on message_stop
|
||||
emitAssistant(true);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -446,6 +612,37 @@ export class ClaudeEngine implements AgentEngine {
|
||||
// Log full result for debugging
|
||||
console.error(`[ClaudeEngine] Result message: ${JSON.stringify(resultRecord, null, 2)}`);
|
||||
|
||||
// Extract and emit usage statistics
|
||||
const usage = resultRecord.usage as Record<string, unknown> | undefined;
|
||||
const totalCostUsd =
|
||||
typeof resultRecord.total_cost_usd === 'number' ? resultRecord.total_cost_usd : 0;
|
||||
const durationMs =
|
||||
typeof resultRecord.duration_ms === 'number' ? resultRecord.duration_ms : 0;
|
||||
const numTurns = typeof resultRecord.num_turns === 'number' ? resultRecord.num_turns : 0;
|
||||
|
||||
if (usage || totalCostUsd > 0) {
|
||||
ctx.emit({
|
||||
type: 'usage',
|
||||
data: {
|
||||
sessionId,
|
||||
requestId,
|
||||
inputTokens: typeof usage?.input_tokens === 'number' ? usage.input_tokens : 0,
|
||||
outputTokens: typeof usage?.output_tokens === 'number' ? usage.output_tokens : 0,
|
||||
cacheReadInputTokens:
|
||||
typeof usage?.cache_read_input_tokens === 'number'
|
||||
? usage.cache_read_input_tokens
|
||||
: undefined,
|
||||
cacheCreationInputTokens:
|
||||
typeof usage?.cache_creation_input_tokens === 'number'
|
||||
? usage.cache_creation_input_tokens
|
||||
: undefined,
|
||||
totalCostUsd,
|
||||
durationMs,
|
||||
numTurns,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check if result contains errors (SDK puts error details here)
|
||||
// Note: is_error can be true even with empty errors array
|
||||
if (resultRecord.is_error) {
|
||||
@@ -640,11 +837,31 @@ export class ClaudeEngine implements AgentEngine {
|
||||
|
||||
/**
|
||||
* Extract content from SDK message.
|
||||
* Handles various message structures from Claude Agent SDK:
|
||||
* - result.result (final result text)
|
||||
* - assistant.message (nested message content)
|
||||
* - content/text (direct content fields)
|
||||
* - content[] (array of content blocks)
|
||||
*
|
||||
* @param message - The message object to extract content from
|
||||
* @param depth - Current recursion depth (max 3 to prevent infinite loops)
|
||||
*/
|
||||
private extractMessageContent(message: unknown): string | undefined {
|
||||
if (!message || typeof message !== 'object') return undefined;
|
||||
private extractMessageContent(message: unknown, depth = 0): string | undefined {
|
||||
// Prevent infinite recursion
|
||||
if (depth > 3 || !message || typeof message !== 'object') return undefined;
|
||||
const record = message as Record<string, unknown>;
|
||||
|
||||
// Handle result message: result field contains final text
|
||||
if (typeof record.result === 'string') {
|
||||
return record.result.trim();
|
||||
}
|
||||
|
||||
// Handle assistant message: message field may contain nested content
|
||||
if (record.message && typeof record.message === 'object') {
|
||||
const nested = this.extractMessageContent(record.message, depth + 1);
|
||||
if (nested) return nested;
|
||||
}
|
||||
|
||||
// Try common content fields
|
||||
if (typeof record.content === 'string') {
|
||||
return record.content.trim();
|
||||
|
||||
@@ -122,7 +122,22 @@ export async function createMessage(
|
||||
createdAt: input.createdAt || now,
|
||||
};
|
||||
|
||||
await db.insert(messages).values(messageData);
|
||||
await db
|
||||
.insert(messages)
|
||||
.values(messageData)
|
||||
.onConflictDoUpdate({
|
||||
target: messages.id,
|
||||
set: {
|
||||
role: messageData.role,
|
||||
messageType: messageData.messageType,
|
||||
content: messageData.content,
|
||||
metadata: messageData.metadata,
|
||||
sessionId: messageData.sessionId,
|
||||
conversationId: messageData.conversationId,
|
||||
cliSource: messageData.cliSource,
|
||||
requestId: messageData.requestId,
|
||||
},
|
||||
});
|
||||
|
||||
return rowToMessage(messageData);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,8 @@ export class AgentStreamManager {
|
||||
return event.data?.sessionId;
|
||||
case 'error':
|
||||
return event.data?.sessionId;
|
||||
case 'usage':
|
||||
return event.data?.sessionId;
|
||||
case 'heartbeat':
|
||||
return undefined;
|
||||
default:
|
||||
|
||||
@@ -48,12 +48,26 @@ export interface AgentHeartbeatEvent {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** Usage statistics for a request */
|
||||
export interface AgentUsageStats {
|
||||
sessionId: string;
|
||||
requestId?: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens?: number;
|
||||
cacheCreationInputTokens?: number;
|
||||
totalCostUsd: number;
|
||||
durationMs: number;
|
||||
numTurns: number;
|
||||
}
|
||||
|
||||
export type RealtimeEvent =
|
||||
| { type: 'message'; data: AgentMessage }
|
||||
| { type: 'status'; data: AgentStatusEvent }
|
||||
| { type: 'error'; error: string; data?: { sessionId?: string; requestId?: string } }
|
||||
| { type: 'connected'; data: AgentConnectedEvent }
|
||||
| { type: 'heartbeat'; data: AgentHeartbeatEvent };
|
||||
| { type: 'heartbeat'; data: AgentHeartbeatEvent }
|
||||
| { type: 'usage'; data: AgentUsageStats };
|
||||
|
||||
// ============================================================
|
||||
// HTTP API Contracts
|
||||
|
||||
Generated
+120
@@ -86,6 +86,9 @@ importers:
|
||||
hnswlib-wasm-static:
|
||||
specifier: 0.8.5
|
||||
version: 0.8.5
|
||||
markstream-vue:
|
||||
specifier: 0.0.3-beta.5
|
||||
version: 0.0.3-beta.5(vue@3.5.25)
|
||||
vue:
|
||||
specifier: ^3.5.13
|
||||
version: 3.5.25(typescript@5.9.3)
|
||||
@@ -1417,6 +1420,23 @@ packages:
|
||||
ipaddr.js: 2.3.0
|
||||
dev: false
|
||||
|
||||
/@floating-ui/core@1.7.3:
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
dev: false
|
||||
|
||||
/@floating-ui/dom@1.7.4:
|
||||
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
|
||||
dependencies:
|
||||
'@floating-ui/core': 1.7.3
|
||||
'@floating-ui/utils': 0.2.10
|
||||
dev: false
|
||||
|
||||
/@floating-ui/utils@0.2.10:
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
dev: false
|
||||
|
||||
/@huggingface/jinja@0.2.2:
|
||||
resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -6474,6 +6494,12 @@ packages:
|
||||
uhyphen: 0.2.0
|
||||
dev: true
|
||||
|
||||
/linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
dev: false
|
||||
|
||||
/lint-staged@15.5.2:
|
||||
resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==}
|
||||
engines: {node: '>=18.12.0'}
|
||||
@@ -6660,6 +6686,74 @@ packages:
|
||||
resolution: {integrity: sha512-DHnZAD4phTbZ+qnJdjoNEVU1NecYoSdbOOoVmTDH46AuxDkEVh3MxTVpXq10GtcTC6mndN9dkv1rNfpjRcLnOw==}
|
||||
dev: true
|
||||
|
||||
/markdown-it-container@4.0.0:
|
||||
resolution: {integrity: sha512-HaNccxUH0l7BNGYbFbjmGpf5aLHAMTinqRZQAEQbMr2cdD3z91Q6kIo1oUn1CQndkT03jat6ckrdRYuwwqLlQw==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-footnote@4.0.0:
|
||||
resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-ins@4.0.0:
|
||||
resolution: {integrity: sha512-sWbjK2DprrkINE4oYDhHdCijGT+MIDhEupjSHLXe5UXeVr5qmVxs/nTUVtgi0Oh/qtF+QKV0tNWDhQBEPxiMew==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-mark@4.0.0:
|
||||
resolution: {integrity: sha512-YLhzaOsU9THO/cal0lUjfMjrqSMPjjyjChYM7oyj4DnyaXEzA8gnW6cVJeyCrCVeyesrY2PlEdUYJSPFYL4Nkg==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-sub@2.0.0:
|
||||
resolution: {integrity: sha512-iCBKgwCkfQBRg2vApy9vx1C1Tu6D8XYo8NvevI3OlwzBRmiMtsJ2sXupBgEA7PPxiDwNni3qIUkhZ6j5wofDUA==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-sup@2.0.0:
|
||||
resolution: {integrity: sha512-5VgmdKlkBd8sgXuoDoxMpiU+BiEt3I49GItBzzw7Mxq9CxvnhE/k09HFli09zgfFDRixDQDfDxi0mgBCXtaTvA==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-task-checkbox@1.0.6:
|
||||
resolution: {integrity: sha512-7pxkHuvqTOu3iwVGmDPeYjQg+AIS9VQxzyLP9JCg9lBjgPAJXGEkChK6A2iFuj3tS0GV3HG2u5AMNhcQqwxpJw==}
|
||||
dev: false
|
||||
|
||||
/markdown-it-ts@0.0.2:
|
||||
resolution: {integrity: sha512-QKNmck9IHgsCqJkvk5Zgw29534WZldNtuCnsEKm5HX80XsEUGfX1m3vexY9jmfHYDq8469fAsbwp78bRusI7HQ==}
|
||||
engines: {node: '>=18'}
|
||||
dependencies:
|
||||
entities: 4.5.0
|
||||
linkify-it: 5.0.0
|
||||
mdurl: 2.0.0
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
dev: false
|
||||
|
||||
/markstream-vue@0.0.3-beta.5(vue@3.5.25):
|
||||
resolution: {integrity: sha512-mqrwry/nWf25yNcSvdXijh5gZT8oC5wLafPncydFjC8Wtc7Zvr8MkTlRjiWP9wczlQxCr1GW2AHxZDQbKPgrJQ==}
|
||||
peerDependencies:
|
||||
katex: '>=0.16.22'
|
||||
mermaid: '>=11'
|
||||
shiki: ^3.13.0
|
||||
stream-markdown: '>=0.0.11'
|
||||
stream-monaco: '>=0.0.8'
|
||||
vue: '>=3.0.0'
|
||||
vue-i18n: '>=9'
|
||||
peerDependenciesMeta:
|
||||
katex:
|
||||
optional: true
|
||||
mermaid:
|
||||
optional: true
|
||||
shiki:
|
||||
optional: true
|
||||
stream-markdown:
|
||||
optional: true
|
||||
stream-monaco:
|
||||
optional: true
|
||||
vue-i18n:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.4
|
||||
stream-markdown-parser: 0.0.42
|
||||
vue: 3.5.25(typescript@5.9.3)
|
||||
dev: false
|
||||
|
||||
/marky@1.3.0:
|
||||
resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==}
|
||||
dev: true
|
||||
@@ -6668,6 +6762,10 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
/mdurl@2.0.0:
|
||||
resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
|
||||
dev: false
|
||||
|
||||
/media-typer@1.1.0:
|
||||
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -7556,6 +7654,11 @@ packages:
|
||||
end-of-stream: 1.4.5
|
||||
once: 1.4.0
|
||||
|
||||
/punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -8105,6 +8208,19 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
dev: true
|
||||
|
||||
/stream-markdown-parser@0.0.42:
|
||||
resolution: {integrity: sha512-e8mn3NW7moO/W5KsZ5jWg/iqw8PceuZq4bzynUf1yc+fUBGqu5SKu5SXDF7EH4jC9EDQiUsK49Fne/G/NYS7hQ==}
|
||||
dependencies:
|
||||
markdown-it-container: 4.0.0
|
||||
markdown-it-footnote: 4.0.0
|
||||
markdown-it-ins: 4.0.0
|
||||
markdown-it-mark: 4.0.0
|
||||
markdown-it-sub: 2.0.0
|
||||
markdown-it-sup: 2.0.0
|
||||
markdown-it-task-checkbox: 1.0.6
|
||||
markdown-it-ts: 0.0.2
|
||||
dev: false
|
||||
|
||||
/streamx@2.23.0:
|
||||
resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==}
|
||||
dependencies:
|
||||
@@ -8658,6 +8774,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
/uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
dev: false
|
||||
|
||||
/ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
dev: true
|
||||
|
||||
Reference in New Issue
Block a user