Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,13 @@ records/**

# Documentation
CLAUDE.md

# Environment variables
.env

# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@
"fuse.js": "^7.1.0",
"lexical": "^0.38.2",
"lexical-vue": "^0.14.1",
"marked": "^17.0.1",
"markstream-vue": "^0.0.4",
"mermaid": "^11.6.0",
"motion-v": "^1.7.4",
"path-browserify-esm": "^1.0.6",
"pinia": "^3.0.4",
Expand Down
10 changes: 7 additions & 3 deletions src/services/webViewService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,12 +259,15 @@ export class WebViewService implements IWebViewService {
vscode.Uri.joinPath(extensionUri, 'dist', 'media', 'style.css')
);

// CSP: 'unsafe-eval' and 'blob:' required for mermaid diagram rendering
// Mermaid v10+ uses dynamic ESM imports and eval for diagram parsing
// See: https://github.com/mermaid-js/mermaid/issues/5453
const csp = [
`default-src 'none';`,
`img-src ${webview.cspSource} https: data:;`,
`style-src ${webview.cspSource} 'unsafe-inline' https://*.vscode-cdn.net;`,
`font-src ${webview.cspSource} data:;`,
`script-src ${webview.cspSource} 'nonce-${nonce}';`,
`script-src ${webview.cspSource} 'nonce-${nonce}' 'unsafe-eval' blob:;`,
`connect-src ${webview.cspSource} https:;`,
`worker-src ${webview.cspSource} blob:;`,
].join(' ');
Expand Down Expand Up @@ -309,13 +312,14 @@ export class WebViewService implements IWebViewService {
wsUrl = 'ws://localhost:5173';
}

// Vite 开发场景的 CSP:允许连接 devServer 与 HMR 的 ws
// Dev CSP: allows devServer + HMR websocket
// 'unsafe-eval' and 'blob:' required for mermaid (see production CSP comment)
const csp = [
`default-src 'none';`,
`img-src ${webview.cspSource} https: data:;`,
`style-src ${webview.cspSource} 'unsafe-inline' ${origin} https://*.vscode-cdn.net;`,
`font-src ${webview.cspSource} data: ${origin};`,
`script-src ${webview.cspSource} 'nonce-${nonce}' 'unsafe-eval' ${origin};`,
`script-src ${webview.cspSource} 'nonce-${nonce}' 'unsafe-eval' ${origin} blob:;`,
`connect-src ${webview.cspSource} ${origin} ${wsUrl} https:;`,
`worker-src ${webview.cspSource} blob:;`,
].join(' ');
Expand Down
84 changes: 78 additions & 6 deletions src/webview/src/components/Messages/AssistantMessage.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<template>
<div class="assistant-message" :class="messageClasses">
<!-- Interrupted badge -->
<div v-if="isInterrupted" class="interrupted-badge">
<span class="codicon codicon-warning"></span>
Stream interrupted
</div>

<template v-if="typeof message.message.content === 'string'">
<ContentBlock :block="{ type: 'text', text: message.message.content }" :context="context" />
</template>
Expand All @@ -17,6 +23,7 @@

<script setup lang="ts">
import { computed } from 'vue';
import { useSignal } from '@gn8/alien-signals-vue';
import type { Message } from '../../models/Message';
import type { ToolContext } from '../../types/tool';
import ContentBlock from './ContentBlock.vue';
Expand All @@ -28,18 +35,35 @@ interface Props {

const props = defineProps<Props>();

// 计算动态 class
// Streaming state - reactively consumed via useSignal
const isStreaming = useSignal(props.message.isStreaming);
const isInterrupted = useSignal(props.message.isInterrupted);

// Compute dynamic classes
const messageClasses = computed(() => {
const classes: string[] = [];
const content = props.message.message.content;

// content 总是数组,检查是否包含 tool_use
// Add streaming class for pulsing indicator
if (isStreaming.value) {
classes.push('streaming');
}

// Add interrupted class for error styling
if (isInterrupted.value) {
classes.push('interrupted');
}

// content is always array, check if contains tool_use
if (Array.isArray(content)) {
const hasToolUse = content.some(wrapper => wrapper.content.type === 'tool_use');
// 只有纯文本消息(没有 tool_use)才显示圆点
return hasToolUse ? [] : ['prefix'];
// Only show dot prefix for text-only messages (no tool_use)
if (!hasToolUse) {
classes.push('prefix');
}
}

return [];
return classes;
});
</script>

Expand All @@ -55,9 +79,10 @@ const messageClasses = computed(() => {
color: var(--vscode-editor-foreground);
word-wrap: break-word;
padding-left: 24px;
position: relative;
}

/* 只在纯文本消息时显示圆点 */
/* Only show dot prefix for text-only messages */
.assistant-message.prefix::before {
content: "\25cf";
position: absolute;
Expand All @@ -67,4 +92,51 @@ const messageClasses = computed(() => {
color: var(--vscode-input-border);
z-index: 1;
}

/* Streaming state - subtle pulsing indicator */
.assistant-message.streaming::after {
content: "";
position: absolute;
left: 4px;
top: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--vscode-progressBar-background, #0078d4);
animation: pulse 1.5s ease-in-out infinite;
}

@keyframes pulse {
0%, 100% {
opacity: 0.4;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}

/* Interrupted state */
.assistant-message.interrupted {
border-left: 2px solid var(--vscode-inputValidation-warningBorder, #c9a500);
}

/* Interrupted badge */
.interrupted-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
margin-bottom: 8px;
background-color: var(--vscode-inputValidation-warningBackground, rgba(201, 165, 0, 0.1));
border: 1px solid var(--vscode-inputValidation-warningBorder, #c9a500);
border-radius: 4px;
font-size: 11px;
color: var(--vscode-inputValidation-warningForeground, #c9a500);
}

.interrupted-badge .codicon {
font-size: 12px;
}
</style>
17 changes: 12 additions & 5 deletions src/webview/src/components/Messages/ContentBlock.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<template>
<!-- 根据 block.type 选择性传递 wrapper -->
<!-- 只有 tool_use 需要 wrapper 来访问 toolResult Signal -->
<!-- Pass wrapper to blocks that need reactive streaming/toolResult access -->
<!-- tool_use: needs wrapper for toolResult Signal -->
<!-- text/thinking: needs wrapper for streaming text Signal -->
<component
v-if="block.type === 'tool_use'"
v-if="needsWrapper"
:is="blockComponent"
:block="block"
:wrapper="wrapper"
:context="context"
/>
<!-- 其他类型不需要 wrapper,避免渲染到 DOM -->
<!-- Other types don't need wrapper -->
<component
v-else
:is="blockComponent"
Expand Down Expand Up @@ -44,7 +45,13 @@ interface Props {

const props = defineProps<Props>();

// 根据 block.type 选择对应的组件
// Blocks that need wrapper for reactive streaming or toolResult access
const needsWrapper = computed(() => {
const type = props.block.type;
return type === 'tool_use' || type === 'text' || type === 'thinking';
});

// Select component based on block.type
const blockComponent = computed(() => {
switch (props.block.type) {
case 'text':
Expand Down
34 changes: 34 additions & 0 deletions src/webview/src/components/Messages/MarkdownContent.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<!-- Streaming mode: virtualization enabled for performance -->
<MarkdownRender
v-if="!final"
:content="content"
:enable-mermaid="true"
:is-dark="isDark"
:max-live-nodes="320"
:live-node-buffer="60"
:code-block-stream="true"
:viewport-priority="true"
:defer-nodes-until-visible="true"
:typewriter="true"
/>
<!-- Final mode: full rendering quality -->
<MarkdownRender
v-else
:content="content"
:enable-mermaid="true"
:is-dark="isDark"
/>
</template>

<script setup lang="ts">
import { MarkdownRender } from 'markstream-vue';
import { useThemeDetector } from '../../utils/themeDetector';

defineProps<{
content: string;
final?: boolean;
}>();

const { isDark } = useThemeDetector();
</script>
Loading