feat(chat): add workspace chat, attachments and agent delegation

This commit is contained in:
2026-09-06 23:17:35 +08:00
parent 637ddbb9bf
commit cec8daac93
39 changed files with 762 additions and 37 deletions
+4
View File
@@ -68,7 +68,11 @@ export interface Conversation {
message_count: number
}
export interface WorkspaceContext { file_path: string; content: string }
export interface ChatMessage {
attachments?: string[]
workspace_context?: WorkspaceContext
activity?: Array<{ type: 'thinking'; text: string } | { type: 'tool'; tool_call_id: string }>
versions?: string[]
message_id: string
+2 -1
View File
@@ -7,6 +7,7 @@ import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
import ChatView from './ChatView.vue'
vi.mock('@/services/agentService', () => ({ listTools: vi.fn().mockResolvedValue([]) }))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/stores/editor', () => ({ useEditorStore: () => ({}) }))
vi.mock('@/stores/workspace', () => ({ useWorkspaceStore: () => ({}) }))
@@ -158,7 +159,7 @@ it('sends on Enter but preserves Shift+Enter and IME confirmation', async () =>
await input.trigger('keydown', { key: 'Enter', shiftKey: true })
expect(send).not.toHaveBeenCalled()
await input.trigger('keydown', { key: 'Enter' })
expect(send).toHaveBeenCalledWith('问题')
expect(send).toHaveBeenCalledWith('问题', undefined, undefined)
await input.trigger('keydown', { key: 'Enter', repeat: true })
expect(send).toHaveBeenCalledTimes(1)
wrapper.unmount()
+39 -9
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { Citation } from '@/contracts'
import type { Citation, WorkspaceContext } from '@/contracts'
import { useChatStore } from '@/stores/chat'
import { useProviderStore } from '@/stores/provider'
import { useSkillStore } from '@/stores/skill'
@@ -9,11 +9,18 @@ import { useCitationNavigation } from '@/composables/useCitationNavigation'
import { t } from '@/i18n'
import ChatPersonaDialog from './ChatPersonaDialog.vue'
import { useChatPreferences } from '@/stores/chatPreferences'
import { listTools } from '@/services/agentService'
import type { ToolDefinition } from '@/contracts'
import { usedCitations } from '@/utils/usedCitations'
const props = defineProps<{ workspaceContext?: WorkspaceContext; embedded?: boolean }>()
const chatStore = useChatStore()
const preferences = useChatPreferences()
const showPersona = ref(false)
const settingsExpanded = ref(false)
const imageTools = ref<ToolDefinition[]>([])
const uploadInput = ref<HTMLInputElement | null>(null)
async function selectFiles(e: Event) { const input=e.target as HTMLInputElement; await chatStore.uploadFiles(Array.from(input.files ?? [])); input.value='' }
const providerStore = useProviderStore()
const skillStore = useSkillStore()
const { openCitation } = useCitationNavigation()
@@ -39,7 +46,7 @@ const activities = computed(() => Object.fromEntries(chatStore.messages.map(mess
async function saveEdit() {
const id = editingMessage.value
if (!id || !editedText.value.trim()) return
await chatStore.retryMessage(id, editedText.value)
await chatStore.retryMessage(id, editedText.value, props.embedded ? props.workspaceContext ?? null : undefined)
editingMessage.value = null
}
const visibleCitations = computed(() => Object.fromEntries(chatStore.messages.map(message => [
@@ -48,6 +55,7 @@ const visibleCitations = computed(() => Object.fromEntries(chatStore.messages.ma
onMounted(async () => {
try {
void listTools().then(items => { if (!disposed) imageTools.value=items.filter(t => /image|vision/i.test(t.name)) }).catch(() => {})
await Promise.all([providerStore.loadProviders(), skillStore.loadSkills(), chatStore.loadConversations()])
if (disposed || providerStore.error) return
const selected = providerStore.enabledProviders.find(p => p.provider_id === chatStore.selectedProviderId)
@@ -74,13 +82,17 @@ watch(() => chatStore.selectedProviderId, async (providerId) => {
await refreshModels(providerId)
})
function send() { void chatStore.sendMessage(chatStore.inputText) }
function send() { void chatStore.sendMessage(chatStore.inputText, undefined, props.embedded ? props.workspaceContext ?? null : undefined) }
function composerKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey || event.isComposing || event.keyCode === 229) return
event.preventDefault()
if (!event.repeat) send()
}
function agentRunId(result?: string): string {
try { const id = JSON.parse(result ?? '{}').run_id; return typeof id === 'string' && /^run_[a-zA-Z0-9]+$/.test(id) ? id : '' } catch { return '' }
}
async function openCitationCard(citation: Citation) {
loadError.value = ''
try {
@@ -93,7 +105,11 @@ async function openCitationCard(citation: Citation) {
<template>
<section class="chat-page">
<header class="chat-toolbar">
<header class="chat-toolbar" :class="{ embedded }">
<template v-if="embedded"><select class="select" aria-label="恢复聊天记录" :value="chatStore.activeConversationId" :disabled="chatStore.isPreparing" @change="chatStore.setActiveConversation(($event.target as HTMLSelectElement).value)"><option v-for="conversation in chatStore.sortedConversations" :key="conversation.conversation_id" :value="conversation.conversation_id">{{ conversation.title }}</option></select></template>
<button v-if="embedded" class="button-secondary config-toggle" :aria-expanded="settingsExpanded" @click="settingsExpanded = !settingsExpanded">{{ settingsExpanded ? '收起聊天设置 ' : '聊天设置 ' }}</button>
<div v-show="!embedded || settingsExpanded" class="chat-settings">
<button v-if="embedded" class="button-secondary" @click="chatStore.createNewConversation()">新对话</button>
<div class="field compact"><label>Provider</label><select v-model="chatStore.selectedProviderId" class="select">
<option v-for="provider in providerStore.enabledProviders" :key="provider.provider_id" :value="provider.provider_id">{{ provider.name }}</option>
</select></div>
@@ -105,16 +121,20 @@ async function openCitationCard(citation: Citation) {
<input v-else id="chat-model-select" v-model="chatStore.selectedModel" class="input" data-field="manual-model" :placeholder="t('填写模型 ID', 'Enter model ID')" />
</div>
<button type="button" class="button-secondary" @click="showPersona = true">{{ t('人设与头像', 'Persona and avatars') }}</button>
<label class="rag-toggle"><input v-model="chatStore.allowAgent" type="checkbox" :disabled="chatStore.isStreaming" />允许创建智能体</label>
<label class="rag-toggle"><input v-model="chatStore.useRag" type="checkbox" :disabled="chatStore.isStreaming" />{{ t('检索知识库', 'Search knowledge base') }}</label>
<span class="subtle">{{ t('模型先回复,按需调用知识库检索;需要提供商支持工具调用,仅显示正文引用的来源。笔记修改和技能调用请使用智能体。', 'The model responds first and can search the knowledge base as needed. Requires tool calling; only cited sources are shown. Use Agent for note edits and skills.') }}</span>
<span class="subtle">{{ t('模型先回复,按需调用知识库检索;需要提供商支持工具调用,仅显示正文引用的来源。开启智能体后可委托笔记和任务工作,写入操作仍需确认。', 'The model responds first and can search the knowledge base as needed. Requires tool calling; only cited sources are shown. Use Agent for note edits and skills.') }}</span>
<details class="ui-disclosure image-routing"><summary>图片降级处理</summary><p class="subtle">优先当前模型视觉选择下列处理器后允许本次会话将图片交给对应服务MCP 优先于插件</p><select v-for="(source,index) in (['mcp_server','plugin'] as const)" :key="source" class="select" :aria-label="index === 0 ? 'MCP 图片处理器' : 'Plugin 图片处理器'" v-model="chatStore.imageFallbackTools[index]"><option value="">不启用此级降级</option><option v-for="tool in imageTools.filter(item => item.source === source)" :key="tool.name" :value="tool.name">{{ tool.name }}</option></select></details>
</div>
</header>
<div v-if="workspaceContext" class="notice-banner">每次发送附带当前文件含未保存编辑{{ workspaceContext.file_path }}</div>
<div v-if="chatStore.contextNotice" class="notice-banner" role="status">{{ chatStore.contextNotice }}</div>
<div v-if="loadError || providerStore.error || chatStore.historyError" class="error-banner chat-error">{{ loadError || providerStore.error || chatStore.historyError }}</div>
<main class="message-timeline">
<div v-if="!chatStore.messages.length" class="empty-state"><div><strong>{{ t('开始一段知识对话', 'Start a knowledge conversation') }}</strong><p>{{ t('请先配置模型提供商。聊天记录保存在本地数据库中。', 'Configure a model provider first. Messages are saved in the local database.') }}</p></div></div>
<article v-for="message in chatStore.messages" :key="message.message_id" class="message" :class="message.role">
<div class="avatar"><img v-if="message.role === 'user' ? preferences.settings.userAvatar : preferences.settings.aiAvatar" :src="message.role === 'user' ? preferences.settings.userAvatar : preferences.settings.aiAvatar" :alt="message.role === 'user' ? t('我', 'Me') : 'AI'" /><span v-else>{{ message.role === 'user' ? t('', 'You') : 'AI' }}</span></div>
<div class="message-body">
<div class="message-body"><small v-if="message.attachments?.length">附件:{{ message.attachments.map(id=>id.split('.').at(-1)).join('、') }}</small><details v-if="message.workspace_context" class="ui-disclosure"><summary>发送时的文件:{{ message.workspace_context.file_path }}</summary><pre class="context-snapshot">{{ message.workspace_context.content }}</pre></details>
<details v-if="message.thinking || message.tool_calls?.length || (message.role === 'assistant' && message.message_id === streamingMessageId)" class="thinking ui-disclosure">
<summary>
<span v-if="message.message_id === streamingMessageId && !message.content" class="thinking-indicator" :aria-label="thinkingLabel">
@@ -124,7 +144,7 @@ async function openCitationCard(citation: Citation) {
</summary>
<template v-for="(entry, index) in activities[message.message_id]" :key="index">
<p v-if="entry.text !== undefined">{{ entry.text }}</p>
<div v-else-if="entry.call" class="tool-calls"><div class="item-card"><span class="badge info">{{ entry.call.status }}</span><strong>{{ entry.call.name }}</strong><pre>{{ JSON.stringify(entry.call.parameters, null, 2) }}</pre></div></div>
<div v-else-if="entry.call" class="tool-calls"><div class="item-card"><span class="badge info">{{ entry.call.status }}</span><strong>{{ entry.call.name }}</strong><pre>{{ JSON.stringify(entry.call.parameters, null, 2) }}</pre><a v-if="agentRunId(entry.call.result)" :href="`#/agent/runs/${agentRunId(entry.call.result)}`">查看智能体运行 / 处理权限确认</a></div></div>
</template>
</details>
<div v-if="editingMessage === message.message_id" class="message-edit">
@@ -139,7 +159,7 @@ async function openCitationCard(citation: Citation) {
</div>
<time>{{ new Date(message.created_at).toLocaleTimeString() }}</time>
<div class="message-actions inline-actions">
<button v-if="message.role === 'assistant'" class="button-secondary" :disabled="!chatStore.canSend" @click="chatStore.retryMessage(message.message_id)">{{ t('重新生成', 'Regenerate') }}</button>
<button v-if="message.role === 'assistant'" class="button-secondary" :disabled="!chatStore.canSend" @click="chatStore.retryMessage(message.message_id, undefined, props.embedded ? props.workspaceContext ?? null : undefined)">{{ t('重新生成', 'Regenerate') }}</button>
<button v-if="message.role === 'user' && editingMessage !== message.message_id" class="button-secondary" :disabled="!chatStore.canSend" @click="editingMessage = message.message_id; editedText = message.content">{{ t('编辑', 'Edit') }}</button>
<template v-if="message.versions && message.versions.length > 1">
<button class="button-secondary" :aria-label="t('上一版本', 'Previous version')" :disabled="!chatStore.canSend || message.versions.indexOf(message.message_id) <= 0" @click="chatStore.switchVersion(message.versions[message.versions.indexOf(message.message_id) - 1]!)"></button>
@@ -152,11 +172,13 @@ async function openCitationCard(citation: Citation) {
</article>
</main>
<footer class="composer">
<input ref="uploadInput" type="file" multiple hidden accept=".ppt,.pptx,.docx,.md,.txt,.wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.png,.jpg,.jpeg,.webp" @change="selectFiles" />
<div class="attachment-list"><button class="button-secondary" :disabled="chatStore.uploading || chatStore.isStreaming" @click="uploadInput?.click()">{{ chatStore.uploading ? '上传中' : '上传文件' }}</button><span v-for="(file,index) in chatStore.pendingAttachments" :key="file.attachment_id" class="badge">{{ file.name }} <button aria-label="移除附件" @click="chatStore.pendingAttachments.splice(index,1)">×</button></span></div>
<textarea v-model="chatStore.inputText" class="textarea" :placeholder="t('输入问题,Enter 发送,Shift + Enter 换行', 'Enter to send; Shift + Enter for a new line')"
@keydown="composerKeydown" />
<div class="composer-actions"><span class="subtle">{{ t('回答可能包含错误,请核对 Citation。', 'Answers may contain errors. Verify the citations.') }}</span>
<button v-if="chatStore.isStreaming || chatStore.isPreparing" class="button-danger" @click="chatStore.stopGeneration">{{ t('停止', 'Stop') }}</button>
<button v-else class="button-primary" :disabled="!chatStore.canSend || !chatStore.inputText.trim() || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">{{ t('发送', 'Send') }}</button>
<button v-else class="button-primary" :disabled="!chatStore.canSend || (!chatStore.inputText.trim() && !chatStore.pendingAttachments.length) || !chatStore.selectedProviderId || !chatStore.selectedModel.trim()" @click="send">{{ t('发送', 'Send') }}</button>
</div>
</footer>
<ChatPersonaDialog v-if="showPersona" @close="showPersona = false" />
@@ -164,8 +186,16 @@ async function openCitationCard(citation: Citation) {
</template>
<style scoped>
.context-snapshot { max-height: 180px; overflow: auto; white-space: pre-wrap; }
.chat-page { display: flex; flex-direction: column; height: 100%; min-height: 0; background: radial-gradient(circle at 85% -10%, var(--color-accent-soft), transparent 30%), var(--color-background-primary); }
.chat-toolbar { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); padding: var(--space-md) var(--space-xl); border-bottom: 1px solid var(--color-border-default); background: var(--color-surface-secondary); box-shadow: var(--shadow-sm); z-index: 1; }
.attachment-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.image-routing { flex-basis: 100%; }
.chat-settings { display: flex; align-items: end; flex-wrap: wrap; gap: var(--space-md); width: min(100%, 820px); min-width: 0; margin: 0 auto; }
.chat-settings > .subtle { flex-basis: 100%; }
.chat-toolbar.embedded { flex-shrink: 0; }
.chat-toolbar.embedded .chat-settings { max-height: 210px; overflow: auto; }
.config-toggle { margin-left: auto; }
.compact { min-width: 160px; }
.rag-toggle { display: flex; align-items: center; gap: var(--space-xs); min-height: 36px; color: var(--color-text-secondary); }
.chat-error { margin: var(--space-md) var(--space-xl) 0; }
@@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { useEditorStore } from '@/stores/editor'
import WorkspaceChat from './WorkspaceChat.vue'
import ChatView from './ChatView.vue'
vi.mock('./ChatView.vue', () => ({ default: { props: ['workspaceContext'], template: '<div class="chat-stub">{{ workspaceContext?.content }}</div>' } }))
it('keeps the same floating chat while closing and uses the live unsaved editor contents', async () => {
localStorage.clear()
setActivePinia(createPinia())
const editor = useEditorStore(); editor.currentFilePath = 'draft.md'; editor.content = 'first'
const wrapper = mount(WorkspaceChat, { props: { open: true }, attachTo: document.body })
expect(document.querySelector('.chat-stub')?.textContent).toBe('first')
const chat = wrapper.findComponent(ChatView).vm
await wrapper.setProps({ open: false })
editor.content = 'second'
await wrapper.setProps({ open: true })
expect(wrapper.findComponent(ChatView).vm).toBe(chat)
expect(document.querySelector('.chat-stub')?.textContent).toBe('second')
const before = (document.querySelector('.workspace-chat') as HTMLElement).style.left
document.querySelector('.workspace-chat-handle')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); await wrapper.vm.$nextTick()
expect((document.querySelector('.workspace-chat') as HTMLElement).style.left).not.toBe(before)
wrapper.unmount()
})
it('remembers resized bounds and resets the window', async () => {
setActivePinia(createPinia())
localStorage.setItem('notes-agent.workspace-chat.bounds.v1', JSON.stringify({x:30,y:20,width:420,height:400}))
const wrapper = mount(WorkspaceChat, {props:{open:true},attachTo:document.body})
const panel=document.querySelector('.workspace-chat') as HTMLElement
expect(panel.style.width).toBe('420px')
document.querySelector('.window-resizer')!.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true})); await wrapper.vm.$nextTick()
expect(panel.style.width).toBe('440px')
expect(JSON.parse(localStorage.getItem('notes-agent.workspace-chat.bounds.v1')!).width).toBe(440)
const reset=[...document.querySelectorAll('button')].find(b=>b.textContent==='重置窗口')!
reset.click(); await wrapper.vm.$nextTick()
expect(panel.style.width).toBe('640px')
wrapper.unmount(); localStorage.clear()
})
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ChatView from './ChatView.vue'
import { useEditorStore } from '@/stores/editor'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const editor = useEditorStore()
const context = computed(() => editor.currentFilePath ? { file_path: editor.currentFilePath, content: editor.content } : undefined)
const panel = ref<HTMLElement | null>(null)
const storageKey = 'notes-agent.workspace-chat.bounds.v1'
const width = ref(640), height = ref(680)
const x = ref(Math.max(8, window.innerWidth - 660)), y = ref(64)
try { const saved = JSON.parse(localStorage.getItem(storageKey) ?? 'null'); if (saved && [saved.x,saved.y,saved.width,saved.height].every(Number.isFinite)) { x.value=saved.x; y.value=saved.y; width.value=saved.width; height.value=saved.height } } catch { /* storage unavailable */ }
function save() { try { localStorage.setItem(storageKey, JSON.stringify({x:x.value,y:y.value,width:width.value,height:height.value})) } catch { /* storage unavailable */ } }
function reset() { width.value=640; height.value=680; x.value=window.innerWidth-660; y.value=32; clamp(); save() }
let resizing: { x:number; y:number; width:number; height:number } | null = null
function resizeStart(e: PointerEvent) { if (e.button !== 0) return; resizing={x:e.clientX,y:e.clientY,width:width.value,height:height.value}; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); e.preventDefault() }
function resizeMove(e: PointerEvent) { if (!resizing) return; width.value=resizing.width+e.clientX-resizing.x; height.value=resizing.height+e.clientY-resizing.y; clamp(); save() }
let drag: { id: number; x: number; y: number; left: number; top: number } | null = null
function clamp() {
width.value=Math.min(Math.max(360,width.value),window.innerWidth-16); height.value=Math.min(Math.max(360,height.value),window.innerHeight-16)
x.value = Math.max(8, Math.min(x.value, window.innerWidth - width.value - 8))
y.value = Math.max(8, Math.min(y.value, window.innerHeight - height.value - 8))
}
function start(event: PointerEvent) {
if (event.button !== 0 || (event.target as Element).closest('button,a')) return
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: x.value, top: y.value }
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function move(event: PointerEvent) {
if (!drag || drag.id !== event.pointerId) return
x.value = drag.left + event.clientX - drag.x; y.value = drag.top + event.clientY - drag.y; clamp(); save()
}
function keyboard(event: KeyboardEvent) {
if (!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key)) return
event.preventDefault()
x.value += event.key === 'ArrowRight' ? 20 : event.key === 'ArrowLeft' ? -20 : 0
y.value += event.key === 'ArrowDown' ? 20 : event.key === 'ArrowUp' ? -20 : 0
clamp(); save()
}
onMounted(() => { clamp(); window.addEventListener('resize', clamp) })
onBeforeUnmount(() => window.removeEventListener('resize', clamp))
</script>
<template>
<Teleport to="body">
<section v-show="props.open" ref="panel" class="workspace-chat surface" role="dialog" aria-label="工作区 AI 对话" :style="{ left: x + 'px', top: y + 'px', width: width + 'px', height: height + 'px' }" @keydown.esc.stop="emit('close')">
<header class="workspace-chat-handle" tabindex="0" aria-label="拖动聊天窗口也可使用方向键移动" @pointerdown="start" @pointermove="move" @pointerup="drag = null" @lostpointercapture="drag = null" @keydown="keyboard">
<strong>工作区 AI 对话</strong><button class="button-secondary" @click="reset">重置窗口</button><a href="#/chat"> AI 对话页继续</a><button class="button-secondary" aria-label="关闭聊天窗口" @click="emit('close')">关闭</button>
</header>
<ChatView embedded :workspace-context="context" />
<button class="window-resizer" aria-label="调整聊天窗口大小" title="拖动调整大小" @pointerdown="resizeStart" @pointermove="resizeMove" @pointerup="resizing=null" @lostpointercapture="resizing=null" @keydown.right.prevent="width+=20; clamp(); save()" @keydown.left.prevent="width-=20; clamp(); save()" @keydown.down.prevent="height+=20; clamp(); save()" @keydown.up.prevent="height-=20; clamp(); save()"></button>
</section>
</Teleport>
</template>
<style scoped>
.window-resizer { position:absolute; right:0; bottom:0; width:20px; height:20px; min-height:0; padding:0; border:0; background:transparent; color:var(--color-text-secondary); cursor:nwse-resize; touch-action:none; }
.workspace-chat { position: fixed; z-index: 100; display: flex; flex-direction: column; width: min(640px, calc(100vw - 16px)); height: min(680px, calc(100dvh - 16px)); border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); background: var(--color-background-primary); color: var(--color-text-primary); box-shadow: var(--shadow-md); overflow: hidden; }
.workspace-chat-handle { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; padding: 10px 14px; background: var(--color-surface-secondary); cursor: move; touch-action: none; flex-shrink: 0; }
.workspace-chat-handle strong { flex: 1 1 130px; margin-right: auto; }
.workspace-chat :deep(.chat-page) { flex: 1; }
.workspace-chat :deep(.chat-toolbar) { padding: 10px; gap: 8px; }
.workspace-chat :deep(.message-timeline) { padding: 12px; }
.workspace-chat :deep(.chat-composer) { padding: 12px; }
</style>
+10 -1
View File
@@ -216,10 +216,19 @@ const hasCommandContribution = computed(() =>
</template>
<style scoped>
.plugin-detail { display: grid; gap: var(--space-lg); }
.plugin-detail {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-lg);
max-width: 1180px;
margin-inline: auto;
}
.detail-panel, .detail-grid > div { min-width: 0; }
.contribution-list { overflow-wrap: anywhere; }
.detail-head {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-md);
@@ -34,7 +34,7 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
const lineRule = rules.find(rule => rule.selectorText === '.markdown-content .shiki .line')!
expect(codeRule.style.getPropertyValue('display')).toBe('block')
expect(lineRule.style.getPropertyValue('display')).toBe('block')
expect(lineRule.style.getPropertyValue('min-height')).toBe('1.45em')
expect(lineRule.style.getPropertyValue('min-height')).toBe('1lh')
const rootRule = rules.filter(rule => rule.selectorText === 'html').pop()!
const bodyRule = rules.filter(rule => rule.selectorText === 'body').pop()!
// The embedded document must override the app-shell overflow lock.
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue'
import { useWorkspaceStore } from '@/stores/workspace'
import EditorHeader from '@/features/editor/EditorHeader.vue'
import EditorPane from '@/features/editor/EditorPane.vue'
@@ -7,11 +8,16 @@ import { EditPen } from '@element-plus/icons-vue'
import AppIcon from '@/components/common/AppIcon.vue'
import { t } from '@/i18n'
const WorkspaceChat = defineAsyncComponent(() => import('../chat/WorkspaceChat.vue'))
const chatOpened = ref(false), chatVisible = ref(false)
function openChat() { chatOpened.value = true; chatVisible.value = true }
const workspaceStore = useWorkspaceStore()
</script>
<template>
<div class="workspace-view">
<button class="workspace-chat-launcher button-secondary" aria-label="唤起 AI 聊天" title="AI 聊天" @click="openChat">AI</button>
<WorkspaceChat v-if="chatOpened" :open="chatVisible" @close="chatVisible = false" />
<template v-if="workspaceStore.activeFilePath">
<EditorHeader />
<WorkspacePluginCommands><EditorPane /></WorkspacePluginCommands>
@@ -27,7 +33,10 @@ const workspaceStore = useWorkspaceStore()
</template>
<style scoped>
.workspace-chat-launcher { position: absolute; right: 24px; bottom: 76px; z-index: 11; width: 42px; height: 42px; border-radius: var(--radius-full); background: var(--color-editor-scroll-background); color: var(--color-editor-scroll-text); box-shadow: var(--shadow-sm); }
.workspace-view {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
+3
View File
@@ -3,6 +3,9 @@ import { apiClient } from './apiClient'
import type { ChatMessage, Conversation, ModelEvent, PageMeta } from '@/contracts'
export interface ChatRequest {
workspace_context?: import('@/contracts').WorkspaceContext
allow_agent?: boolean
image_fallback_tools?: string[]
retry_message_id?: string
provider_id: string
model: string
+34
View File
@@ -322,3 +322,37 @@ it('keeps a deleting conversation blocked after reselecting it without blocking
expect(store.isStreaming).toBe(true)
expect(client.cancel).not.toHaveBeenCalled()
})
it('captures fresh workspace contents each send and restores the saved context for page continuation', async () => {
const store = useChatStore()
store.selectedProviderId = 'real'; store.selectedModel = 'model'; store.allowAgent = true
const context = { file_path: 'note.md', content: 'unsaved first' }
await store.sendMessage('first', undefined, context)
context.content = 'unsaved second'
expect(vi.mocked(streamChat).mock.calls[0]![0].workspace_context?.content).toBe('unsaved first')
expect(store.messages[0]?.workspace_context?.content).toBe('unsaved first')
vi.mocked(streamChat).mock.calls[0]![1].onDone?.()
await store.sendMessage('second', undefined, context)
expect(vi.mocked(streamChat).mock.calls[1]![0].workspace_context?.content).toBe('unsaved second')
expect(vi.mocked(streamChat).mock.calls[1]![0].allow_agent).toBe(true)
vi.mocked(streamChat).mock.calls[1]![1].onDone?.()
await store.sendMessage('continue on chat page')
expect(vi.mocked(streamChat).mock.calls[2]![0].workspace_context?.content).toBe('unsaved second')
vi.mocked(streamChat).mock.calls[2]![1].onDone?.()
await store.sendMessage('no active file', undefined, null)
expect(vi.mocked(streamChat).mock.calls[3]![0].workspace_context).toBeUndefined()
expect(vi.mocked(createConversation)).toHaveBeenCalledTimes(1)
})
it('uploads attachments and includes their durable IDs in an attachment-only message', async () => {
const { mediaService } = await import('@/services/mediaService')
const upload = vi.spyOn(mediaService,'upload').mockResolvedValue({attachment_id:'media_test.docx'})
const store=useChatStore(); store.selectedProviderId='real'; store.selectedModel='model'
await store.uploadFiles([new File(['document'],'test.docx')])
expect(store.pendingAttachments[0]?.name).toBe('test.docx')
await store.sendMessage('')
expect(vi.mocked(streamChat).mock.calls[0]![0].attachments).toEqual(['media_test.docx'])
expect(store.messages[0]?.attachments).toEqual(['media_test.docx'])
expect(store.pendingAttachments).toEqual([])
upload.mockRestore()
})
+42 -10
View File
@@ -1,6 +1,6 @@
import { computed, reactive, ref } from 'vue'
import { defineStore } from 'pinia'
import type { ChatMessage, Citation, Conversation } from '@/contracts'
import type { ChatMessage, Citation, Conversation, WorkspaceContext } from '@/contracts'
import {
createConversation as createConversationApi,
listConversationMessages,
@@ -11,6 +11,7 @@ import {
} from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
import { t } from '@/i18n'
import { mediaService } from '@/services/mediaService'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>([])
@@ -20,10 +21,28 @@ export const useChatStore = defineStore('chat', () => {
const isPreparing = ref(false)
const messagesReady = ref(true)
const deletingConversations = reactive(new Set<string>())
const canSend = computed(() => messagesReady.value && !isPreparing.value && !isStreaming.value
const canSend = computed(() => messagesReady.value && !isPreparing.value && !isStreaming.value && !uploading.value
&& (!activeConversationId.value || !deletingConversations.has(activeConversationId.value)))
const uploading = ref(false)
const pendingAttachments = ref<{attachment_id:string;name:string}[]>([])
const imageFallbackTools = ref<string[]>(['',''])
async function uploadFiles(files: File[]) {
if (uploading.value || isStreaming.value) return
uploading.value=true; historyError.value=''
const conversationId=activeConversationId.value
try {
for (const file of files) {
if (pendingAttachments.value.length >= 8) throw new Error('每次最多上传 8 个附件')
const saved = await mediaService.upload(file, crypto.randomUUID())
if (activeConversationId.value !== conversationId) return
pendingAttachments.value.push({...saved,name:file.name})
}
} catch(error) { historyError.value=error instanceof Error ? error.message : '上传失败' }
finally { uploading.value=false }
}
const inputText = ref('')
const useRag = ref(true)
const allowAgent = ref(false)
const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('')
const selectedModel = ref('')
@@ -102,6 +121,7 @@ export const useChatStore = defineStore('chat', () => {
async function setActiveConversation(id: string) {
stopGeneration()
pendingAttachments.value=[]
const version = ++loadVersion
activeConversationId.value = id
messagesReady.value = false
@@ -151,15 +171,19 @@ export const useChatStore = defineStore('chat', () => {
async function createNewConversation() {
stopGeneration()
pendingAttachments.value=[]
historyError.value = ''
contextNotice.value = ''
const conversation = addLocalConversation(t('新对话', 'New conversation'))
try { await persistConversation(conversation) } catch { /* exposed through historyError */ }
}
async function sendMessage(text: string, retryMessageId?: string) {
const content = text.trim()
async function sendMessage(text: string, retryMessageId?: string, workspaceContext?: WorkspaceContext | null) {
const content = text.trim() || (pendingAttachments.value.length ? '请分析附件内容' : '')
if (!content || !canSend.value || !selectedProviderId.value || !selectedModel.value) return
const context = workspaceContext === undefined ? [...messages.value].reverse().find(m => m.role === 'user')?.workspace_context : workspaceContext
const snapshot = context ? { ...context } : undefined
const attachments = pendingAttachments.value.length ? pendingAttachments.value.map(a=>a.attachment_id) : ([...messages.value].reverse().find(m=>m.role==='user')?.attachments ?? [])
const version = ++streamVersion
isPreparing.value = true
historyError.value = ''
@@ -187,7 +211,7 @@ export const useChatStore = defineStore('chat', () => {
const originalMessages = retryTarget ? [...messages.value] : null
const regenerate = retryTarget?.role === 'assistant'
const userMsg: ChatMessage = regenerate ? messages.value[retryIndex - 1]! : {
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content,
message_id: crypto.randomUUID(), conversation_id: conversationId, role: 'user', content, workspace_context: snapshot, attachments,
created_at: new Date().toISOString(),
}
const aiMsg = reactive<ChatMessage>({
@@ -202,6 +226,7 @@ export const useChatStore = defineStore('chat', () => {
if (!regenerate) messages.value.push(userMsg)
messages.value.push(aiMsg)
inputText.value = ''
pendingAttachments.value = []
isStreaming.value = true
conversation.updated_at = new Date().toISOString()
conversation.message_count = messages.value.length
@@ -216,6 +241,9 @@ export const useChatStore = defineStore('chat', () => {
assistant_message_id: aiMsg.message_id,
conversation_title: conversation.title,
use_rag: useRag.value,
allow_agent: allowAgent.value,
attachments, image_fallback_tools: imageFallbackTools.value.filter(Boolean),
workspace_context: snapshot,
messages: messages.value
.filter(message => message.message_id !== aiMsg.message_id)
.map(message => ({ role: message.role, content: message.content,
@@ -250,7 +278,10 @@ export const useChatStore = defineStore('chat', () => {
}
if (event.event === 'ToolCallEnd') {
const call = aiMsg.tool_calls?.find(item => item.tool_call_id === event.data.tool_call_id)
if (call) call.status = event.data.status === 'failed' ? 'error' : 'completed'
if (call) {
call.status = event.data.status === 'failed' ? 'error' : 'completed'
if (event.data.result) call.result = JSON.stringify(event.data.result)
}
}
if (event.event === 'Usage') {
const input = Number(event.data.input_tokens ?? 0)
@@ -259,7 +290,7 @@ export const useChatStore = defineStore('chat', () => {
}
if (event.event === 'Citation') {
aiMsg.citations?.push({
note_id: String(event.data.note_id ?? ''), block_id: String(event.data.block_id ?? ''),
citation_id: String(event.data.citation_id ?? ''), note_id: String(event.data.note_id ?? ''), block_id: String(event.data.block_id ?? ''),
file_path: String(event.data.file_path ?? ''),
heading_path: Array.isArray(event.data.heading_path) ? event.data.heading_path.join(' / ') : String(event.data.heading_path ?? ''),
content: String(event.data.content ?? event.data.snippet ?? ''),
@@ -285,13 +316,13 @@ export const useChatStore = defineStore('chat', () => {
})
}
async function retryMessage(messageId: string, editedText?: string) {
async function retryMessage(messageId: string, editedText?: string, workspaceContext?: WorkspaceContext | null) {
if (!canSend.value) return
const index = messages.value.findIndex(m => m.message_id === messageId)
const message = messages.value[index]
if (!message) return
const text = message.role === 'user' ? editedText : messages.value[index - 1]?.content
if (text?.trim()) await sendMessage(text, messageId)
if (text?.trim()) await sendMessage(text, messageId, workspaceContext !== undefined ? workspaceContext : (message.role === 'user' ? message.workspace_context : messages.value[index - 1]?.workspace_context))
}
async function switchVersion(messageId: string) {
@@ -336,8 +367,9 @@ export const useChatStore = defineStore('chat', () => {
}
return {
uploading, pendingAttachments, imageFallbackTools, uploadFiles,
conversations, activeConversationId, activeConversation, sortedConversations, messages,
isStreaming, isPreparing, canSend, inputText, useRag, selectedSkillId, selectedProviderId, selectedModel, historyError, contextNotice,
isStreaming, isPreparing, canSend, inputText, useRag, allowAgent, selectedSkillId, selectedProviderId, selectedModel, historyError, contextNotice,
loadConversations, setActiveConversation, sendMessage, stopGeneration, createNewConversation, deleteConversation, retryMessage, switchVersion,
}
})