feat: 添加选择功能和附件上传支持,更新多语言文本

This commit is contained in:
jxxghp
2026-06-16 22:55:26 +08:00
parent cd69172a99
commit 33666703af
5 changed files with 481 additions and 22 deletions

View File

@@ -8,6 +8,7 @@ import { useAuthStore, useUserStore } from '@/stores'
type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
type AgentAttachmentKind = 'audio' | 'file' | 'image'
type AgentChoiceStatus = 'pending' | 'selected' | 'expired'
interface AgentToolCall {
id: string
@@ -24,6 +25,21 @@ interface AgentMessageAttachment {
size?: number
}
interface AgentChoiceButton {
label: string
callback_data: string
}
interface AgentChoiceCard {
id: string
title?: string
prompt: string
buttons: AgentChoiceButton[]
status: AgentChoiceStatus
selected_label?: string
selected_value?: string
}
interface AgentChatMessage {
id: string
role: AgentMessageRole
@@ -32,16 +48,43 @@ interface AgentChatMessage {
status: AgentMessageStatus
tools: AgentToolCall[]
attachments: AgentMessageAttachment[]
choices: AgentChoiceCard[]
}
interface AgentStreamEvent {
type: 'start' | 'delta' | 'tool' | 'attachment' | 'done' | 'error'
type: 'start' | 'delta' | 'tool' | 'attachment' | 'choice' | 'done' | 'error'
attachment?: AgentMessageAttachment
choice?: Omit<AgentChoiceCard, 'status'>
content?: string
message?: string
session_id?: string
}
interface AgentPendingAttachment {
id: string
file: File
kind: AgentAttachmentKind
name: string
mime_type: string
size: number
preview_url?: string
}
interface AgentOutgoingFile {
ref: string
name?: string
mime_type?: string
size?: number
local_path?: string
status?: string
}
interface PreparedAgentAttachments {
images: string[]
files: AgentOutgoingFile[]
userAttachments: AgentMessageAttachment[]
}
const { t } = useI18n()
const display = useDisplay()
const authStore = useAuthStore()
@@ -59,10 +102,12 @@ const sending = ref(false)
const streamError = ref('')
const messageListRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLTextAreaElement | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const pendingAttachments = ref<AgentPendingAttachment[]>([])
let abortController: AbortController | null = null
const md = new MarkdownIt({
html: false,
html: true,
breaks: true,
linkify: true,
typographer: true,
@@ -75,7 +120,9 @@ md.use(mdLinkAttributes, {
},
})
const canSend = computed(() => inputText.value.trim().length > 0 && !sending.value)
const canSend = computed(
() => (inputText.value.trim().length > 0 || pendingAttachments.value.length > 0) && !sending.value,
)
// 窄屏下直接全屏,避免聊天内容被压成半屏窄栏。
const drawerWidth = computed(() => (display.mdAndDown.value ? '100vw' : '30rem'))
const hasMessages = computed(() => messages.value.length > 0)
@@ -99,6 +146,7 @@ function normalizeStoredMessages(value: unknown) {
return value.slice(-MAX_PERSISTED_MESSAGES).map(message => ({
...message,
attachments: Array.isArray(message.attachments) ? message.attachments : [],
choices: Array.isArray(message.choices) ? message.choices : [],
tools: Array.isArray(message.tools) ? message.tools : [],
})) as AgentChatMessage[]
}
@@ -158,14 +206,20 @@ function syncInputHeight() {
})
}
function addMessage(role: AgentMessageRole, content: string, status: AgentMessageStatus = 'idle') {
function addMessage(
role: AgentMessageRole,
content: string,
status: AgentMessageStatus = 'idle',
attachments: AgentMessageAttachment[] = [],
) {
const message: AgentChatMessage = {
id: createId(role),
role,
content,
createdAt: Date.now(),
status,
attachments: [],
attachments,
choices: [],
tools: [],
}
messages.value.push(message)
@@ -202,6 +256,14 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
assistantMessage.attachments.push(event.attachment)
}
break
case 'choice':
if (event.choice?.id) {
assistantMessage.choices.push({
...event.choice,
status: 'pending',
})
}
break
case 'done':
if (assistantMessage.status !== 'error') {
assistantMessage.status = 'done'
@@ -279,7 +341,7 @@ function getAttachmentName(attachment: AgentMessageAttachment) {
return attachment.name || (attachment.kind === 'image' ? 'image' : 'attachment')
}
function getAttachmentIcon(attachment: AgentMessageAttachment) {
function getAttachmentIcon(attachment: { kind: AgentAttachmentKind }) {
if (attachment.kind === 'audio') return 'mdi-volume-high'
if (attachment.kind === 'image') return 'mdi-image-outline'
return 'mdi-file-outline'
@@ -292,6 +354,113 @@ function formatAttachmentSize(size?: number) {
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
function getFileKind(file: File): AgentAttachmentKind {
if (file.type.startsWith('image/')) return 'image'
if (file.type.startsWith('audio/')) return 'audio'
return 'file'
}
function openFilePicker() {
fileInputRef.value?.click()
}
function handleFileSelection(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
const nextAttachments = files.map(file => {
const kind = getFileKind(file)
return {
id: createId('attachment'),
file,
kind,
name: file.name,
mime_type: file.type || 'application/octet-stream',
size: file.size,
preview_url: kind === 'image' ? URL.createObjectURL(file) : undefined,
}
})
pendingAttachments.value.push(...nextAttachments)
input.value = ''
}
function removePendingAttachment(id: string) {
const attachment = pendingAttachments.value.find(item => item.id === id)
if (attachment?.preview_url) URL.revokeObjectURL(attachment.preview_url)
pendingAttachments.value = pendingAttachments.value.filter(item => item.id !== id)
}
function clearPendingAttachments() {
pendingAttachments.value.forEach(item => {
if (item.preview_url) URL.revokeObjectURL(item.preview_url)
})
pendingAttachments.value = []
}
function readFileAsDataUrl(file: File) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(reader.error || new Error(t('agentAssistant.uploadFailed')))
reader.readAsDataURL(file)
})
}
async function uploadAgentAttachment(file: File) {
const formData = new FormData()
formData.append('file', file)
formData.append('session_id', sessionId.value)
const response = await fetch(resolveApiUrl('message/agent/upload'), {
method: 'POST',
headers: {
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
body: formData,
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.uploadFailed'))
return result.data as AgentMessageAttachment & AgentOutgoingFile
}
async function prepareAgentAttachments(items: AgentPendingAttachment[]): Promise<PreparedAgentAttachments> {
const images: string[] = []
const files: AgentOutgoingFile[] = []
const userAttachments: AgentMessageAttachment[] = []
for (const item of items) {
const imageDataUrl = item.kind === 'image' ? await readFileAsDataUrl(item.file) : ''
const uploaded = await uploadAgentAttachment(item.file)
const displayAttachment: AgentMessageAttachment = {
kind: item.kind,
url: item.kind === 'image' ? imageDataUrl : uploaded.url,
download_url: uploaded.download_url || uploaded.url,
name: item.name,
mime_type: item.mime_type,
size: item.size,
}
if (imageDataUrl) images.push(imageDataUrl)
files.push({
ref: uploaded.ref || uploaded.url,
name: uploaded.name || item.name,
mime_type: uploaded.mime_type || item.mime_type,
size: uploaded.size || item.size,
local_path: uploaded.local_path,
status: uploaded.status || 'ready',
})
userAttachments.push(displayAttachment)
}
return { images, files, userAttachments }
}
function getVisibleViewportHeight() {
if (typeof window === 'undefined') return '100dvh'
@@ -305,18 +474,20 @@ function syncDrawerViewportHeight() {
drawerViewportHeight.value = getVisibleViewportHeight()
}
async function sendMessage() {
const text = inputText.value.trim()
if (!text || sending.value) return
async function streamAgentMessage(
text: string,
images: string[] = [],
files: AgentOutgoingFile[] = [],
userAttachments: AgentMessageAttachment[] = [],
echoUser = true,
) {
const content = text.trim()
if (!content && !images.length && !files.length) return
streamError.value = ''
inputText.value = ''
syncInputHeight()
addMessage('user', text, 'done')
if (echoUser) addMessage('user', content, 'done', userAttachments)
const assistantMessage = addMessage('assistant', '', 'streaming')
abortController = new AbortController()
sending.value = true
try {
const response = await fetch(resolveApiUrl('message/agent/stream'), {
@@ -326,8 +497,10 @@ async function sendMessage() {
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
body: JSON.stringify({
text,
text: content,
session_id: sessionId.value,
images,
files,
}),
credentials: 'include',
signal: abortController.signal,
@@ -354,13 +527,74 @@ async function sendMessage() {
streamError.value = assistantMessage.content
markToolsDone(assistantMessage)
} finally {
sending.value = false
abortController = null
persistState()
scrollToBottom()
}
}
async function sendMessage() {
const text = inputText.value.trim()
const attachments = [...pendingAttachments.value]
if ((!text && !attachments.length) || sending.value) return
streamError.value = ''
inputText.value = ''
clearPendingAttachments()
syncInputHeight()
sending.value = true
try {
const prepared = await prepareAgentAttachments(attachments)
await streamAgentMessage(text, prepared.images, prepared.files, prepared.userAttachments)
} catch (error: any) {
streamError.value = error?.message || t('agentAssistant.uploadFailed')
addMessage('assistant', streamError.value, 'error')
} finally {
sending.value = false
}
}
async function handleChoiceClick(choice: AgentChoiceCard, button: AgentChoiceButton) {
if (sending.value || choice.status !== 'pending') return
sending.value = true
streamError.value = ''
try {
const response = await fetch(resolveApiUrl('message/agent/callback'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(authStore.token ? { Authorization: `Bearer ${authStore.token}` } : {}),
},
body: JSON.stringify({
session_id: sessionId.value,
callback_data: button.callback_data,
}),
credentials: 'include',
})
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim())
const result = await response.json()
if (!result?.success) throw new Error(result?.message || t('agentAssistant.choiceExpired'))
choice.status = 'selected'
choice.selected_label = result.data?.feedback?.selected_label || button.label
choice.selected_value = result.data?.feedback?.selected_value || result.data?.message
persistState()
await streamAgentMessage(String(result.data?.message || ''), [], [], [], false)
} catch (error: any) {
choice.status = 'expired'
streamError.value = error?.message || t('agentAssistant.choiceExpired')
persistState()
} finally {
sending.value = false
}
}
function stopGeneration() {
abortController?.abort()
}
@@ -370,6 +604,7 @@ function startNewSession() {
sessionId.value = createSessionId()
messages.value = []
streamError.value = ''
clearPendingAttachments()
persistState()
}
@@ -433,6 +668,7 @@ onMounted(() => {
})
onScopeDispose(clearAgentAssistantOpenState)
onScopeDispose(clearPendingAttachments)
onScopeDispose(() => {
if (typeof window === 'undefined') return
@@ -531,6 +767,35 @@ onScopeDispose(() => {
v-html="renderMarkdown(message.content)"
/>
<div v-if="message.choices.length" class="agent-assistant-choices">
<div v-for="choice in message.choices" :key="choice.id" class="agent-assistant-choice">
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
<div class="agent-assistant-choice__prompt">{{ choice.prompt }}</div>
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
<VIcon icon="mdi-check-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceSelected', { option: choice.selected_label }) }}</span>
</div>
<div v-else-if="choice.status === 'expired'" class="agent-assistant-choice__selected is-expired">
<VIcon icon="mdi-alert-circle-outline" size="16" />
<span>{{ t('agentAssistant.choiceExpired') }}</span>
</div>
<div class="agent-assistant-choice__buttons">
<VBtn
v-for="button in choice.buttons"
:key="button.callback_data"
size="small"
rounded="lg"
variant="tonal"
color="primary"
:disabled="sending || choice.status !== 'pending'"
@click="handleChoiceClick(choice, button)"
>
{{ button.label }}
</VBtn>
</div>
</div>
</div>
<div v-if="message.attachments.length" class="agent-assistant-attachments">
<div
v-for="attachment in message.attachments"
@@ -587,7 +852,12 @@ onScopeDispose(() => {
</div>
<div
v-if="!message.content && !message.attachments.length && message.status === 'streaming'"
v-if="
!message.content &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
"
class="agent-assistant-typing"
>
<span />
@@ -601,7 +871,48 @@ onScopeDispose(() => {
<VAlert v-if="streamError" type="error" variant="tonal" density="compact" class="mb-3">
{{ streamError }}
</VAlert>
<div v-if="pendingAttachments.length" class="agent-assistant-pending-files">
<div v-for="attachment in pendingAttachments" :key="attachment.id" class="agent-assistant-pending-file">
<img
v-if="attachment.kind === 'image' && attachment.preview_url"
class="agent-assistant-pending-file__preview"
:src="attachment.preview_url"
:alt="attachment.name"
/>
<VIcon v-else :icon="getAttachmentIcon(attachment)" size="18" />
<div class="agent-assistant-pending-file__text">
<span>{{ attachment.name }}</span>
<small>{{ formatAttachmentSize(attachment.size) || attachment.mime_type }}</small>
</div>
<IconBtn
size="x-small"
:disabled="sending"
:title="t('agentAssistant.removeAttachment')"
:aria-label="t('agentAssistant.removeAttachment')"
@click="removePendingAttachment(attachment.id)"
>
<VIcon icon="mdi-close" size="16" />
</IconBtn>
</div>
</div>
<div class="agent-assistant-input">
<input
ref="fileInputRef"
class="agent-assistant-file-input"
type="file"
multiple
:disabled="sending"
@change="handleFileSelection"
/>
<IconBtn
class="agent-assistant-attach"
:disabled="sending"
:title="t('agentAssistant.attachFile')"
:aria-label="t('agentAssistant.attachFile')"
@click="openFilePicker"
>
<VIcon icon="mdi-plus" />
</IconBtn>
<textarea
ref="inputRef"
v-model="inputText"
@@ -721,7 +1032,7 @@ onScopeDispose(() => {
.agent-assistant-messages {
overflow-y: auto;
padding-block: 1rem calc(env(safe-area-inset-bottom, 0px) + 8.4rem);
padding-block: 1rem calc(env(safe-area-inset-bottom, 0px) + 12rem);
padding-inline: 1rem;
scrollbar-width: thin;
}
@@ -830,6 +1141,80 @@ onScopeDispose(() => {
padding-inline: 0.6rem;
}
.agent-assistant-choices {
display: grid;
gap: 0.55rem;
inline-size: min(100%, 34rem);
margin-block-start: 0.5rem;
}
.agent-assistant-choice {
display: grid;
border: 1px solid rgba(25, 178, 160, 28%);
border-radius: 14px;
backdrop-filter: blur(var(--agent-assistant-panel-blur));
background: rgba(25, 178, 160, 8%);
gap: 0.65rem;
padding-block: 0.75rem;
padding-inline: 0.8rem;
}
.agent-assistant-choice__title {
color: rgba(var(--v-theme-on-surface), 0.82);
font-size: 0.78rem;
font-weight: 700;
line-height: 1.3;
}
.agent-assistant-choice__prompt {
color: rgba(var(--v-theme-on-surface), 0.9);
font-size: 0.92rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.agent-assistant-choice__selected {
display: inline-flex;
align-items: center;
border: 1px solid rgba(25, 178, 160, 28%);
border-radius: 10px;
background: rgba(25, 178, 160, 10%);
color: rgba(var(--v-theme-on-surface), 0.78);
column-gap: 0.4rem;
font-size: 0.8rem;
inline-size: fit-content;
max-inline-size: 100%;
padding-block: 0.35rem;
padding-inline: 0.5rem;
}
.agent-assistant-choice__selected span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-assistant-choice__selected.is-expired {
border-color: rgba(var(--v-theme-warning), 0.28);
background: rgba(var(--v-theme-warning), 0.1);
}
.agent-assistant-choice__buttons {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
:deep(.v-btn) {
max-inline-size: 100%;
}
:deep(.v-btn__content) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agent-assistant-typing {
display: inline-flex;
border: 1px solid var(--agent-assistant-assistant-border);
@@ -869,6 +1254,56 @@ onScopeDispose(() => {
pointer-events: auto;
}
.agent-assistant-pending-files {
display: grid;
padding: 0.55rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 14px;
backdrop-filter: blur(var(--agent-assistant-panel-blur));
background: var(--agent-assistant-panel-bg);
box-shadow: var(--app-surface-shadow);
gap: 0.45rem;
margin-block-end: 0.55rem;
max-block-size: 8rem;
overflow-y: auto;
scrollbar-width: thin;
}
.agent-assistant-pending-file {
display: grid;
align-items: center;
border-radius: 10px;
column-gap: 0.55rem;
grid-template-columns: auto 1fr auto;
min-inline-size: 0;
padding-block: 0.25rem;
padding-inline: 0.35rem 0.15rem;
}
.agent-assistant-pending-file__preview {
border-radius: 8px;
block-size: 2.1rem;
inline-size: 2.1rem;
object-fit: cover;
}
.agent-assistant-pending-file__text {
display: grid;
min-inline-size: 0;
}
.agent-assistant-pending-file__text span,
.agent-assistant-pending-file__text small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-assistant-pending-file__text small {
color: rgba(var(--v-theme-on-surface), 0.58);
font-size: 0.72rem;
}
.agent-assistant-input {
display: grid;
align-items: center;
@@ -877,12 +1312,21 @@ onScopeDispose(() => {
backdrop-filter: blur(var(--agent-assistant-panel-blur));
background: var(--agent-assistant-panel-bg);
box-shadow: var(--app-surface-shadow);
grid-template-columns: 1fr auto;
column-gap: 0.25rem;
grid-template-columns: auto 1fr auto;
min-block-size: 3.25rem;
padding-inline: 0.85rem 0.35rem;
padding-inline: 0.35rem;
pointer-events: auto;
}
.agent-assistant-file-input {
display: none;
}
.agent-assistant-attach {
align-self: center;
}
.agent-assistant-textarea {
box-sizing: border-box;
align-self: center;
@@ -1055,7 +1499,7 @@ onScopeDispose(() => {
}
.agent-assistant-messages {
padding-block: 0.85rem calc(env(safe-area-inset-bottom, 0px) + 8.2rem);
padding-block: 0.85rem calc(env(safe-area-inset-bottom, 0px) + 11.8rem);
padding-inline: 0.85rem;
}

View File

@@ -55,7 +55,7 @@ const globalSettingsStore = useGlobalSettingsStore()
// 获取用户权限信息
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const showAgentAssistant = computed(() => canAdmin.value && globalSettingsStore.get('AI_AGENT_ENABLE') === true)
const showAgentAssistant = computed(() => globalSettingsStore.get('AI_AGENT_ENABLE') === true)
// 开始菜单项
const startMenus = ref<NavMenu[]>([])

View File

@@ -708,6 +708,11 @@ export default {
placeholder: 'Ask MoviePilot...',
stop: 'Stop generating',
download: 'Download',
attachFile: 'Choose image or file',
removeAttachment: 'Remove attachment',
uploadFailed: 'Attachment upload failed',
choiceSelected: 'Selected: {option}',
choiceExpired: 'This choice expired. Please ask again.',
error: 'Assistant response failed',
noStream: 'This browser cannot read streaming responses',
},

View File

@@ -704,6 +704,11 @@ export default {
placeholder: '询问 MoviePilot...',
stop: '停止生成',
download: '下载',
attachFile: '选择图片或文件',
removeAttachment: '移除附件',
uploadFailed: '附件上传失败',
choiceSelected: '已选择:{option}',
choiceExpired: '该选择已失效,请重新发起选择',
error: '智能助手响应失败',
noStream: '当前浏览器无法读取流式响应',
},

View File

@@ -704,6 +704,11 @@ export default {
placeholder: '詢問 MoviePilot...',
stop: '停止生成',
download: '下載',
attachFile: '選擇圖片或文件',
removeAttachment: '移除附件',
uploadFailed: '附件上傳失敗',
choiceSelected: '已選擇:{option}',
choiceExpired: '該選擇已失效,請重新發起選擇',
error: '智能助手響應失敗',
noStream: '目前瀏覽器無法讀取串流響應',
},