fix: ccr bug fix

This commit is contained in:
hangerye
2025-12-15 20:53:37 +08:00
parent 704e04de0e
commit 67d41cc810
15 changed files with 988 additions and 110 deletions
+3 -1
View File
@@ -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;
}
}
}
@@ -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);
@@ -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 {
@@ -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>
@@ -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>
@@ -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,
+1
View File
@@ -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"
},
+238 -21
View File
@@ -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();
+16 -1
View File
@@ -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:
+15 -1
View File
@@ -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
+120
View File
@@ -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