Compare commits

..
14 Commits
41 changed files with 1563 additions and 328 deletions
+1 -1
View File
@@ -60,7 +60,7 @@
},
"src/ace-config.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 31
"count": 24
},
"no-useless-escape": {
"count": 4
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.15.4",
"version": "2.15.5",
"private": true,
"type": "module",
"bin": "dist/service.js",
+3 -2
View File
@@ -65,13 +65,14 @@ http {
root html;
}
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$) {
location ~ ^/api/v1/(system/(message|progress/|logging)|search/.*/stream$|message/agent/stream$) {
# SSE MIME类型设置
default_type text/event-stream;
# 禁用缓存
add_header Cache-Control no-cache;
add_header Cache-Control "no-cache, no-transform";
add_header X-Accel-Buffering no;
gzip off;
proxy_buffering off;
proxy_cache off;
+1 -1
View File
@@ -72,7 +72,7 @@ function handleCancel() {
</div>
</div>
</VCardItem>
<VCardActions class="mx-auto">
<VCardActions class="app-confirm-dialog-actions mx-auto">
<VBtn variant="tonal" color="secondary" class="px-5" @click="handleCancel">
{{ cancelText }}
</VBtn>
+120 -6
View File
@@ -7,17 +7,22 @@ interface AceToken {
value: string
}
interface WordListSyntaxMode {
interface WordListMode {
getTokenizer: () => {
getLineTokens: (line: string, state: string) => { tokens: AceToken[] }
}
}
const WordListSyntaxMode = ace.require('ace/mode/word_list_syntax').Mode as new () => WordListSyntaxMode
const tokenizer = new WordListSyntaxMode().getTokenizer()
const WordListMode = ace.require('ace/mode/word_list').Mode as new (options?: { syntax?: boolean }) => WordListMode
const syntaxTokenizer = new WordListMode({ syntax: true }).getTokenizer()
const plainTokenizer = new WordListMode().getTokenizer()
function tokenize(line: string) {
return tokenizer.getLineTokens(line, 'start').tokens
return syntaxTokenizer.getLineTokens(line, 'start').tokens
}
function tokenizePlain(line: string) {
return plainTokenizer.getLineTokens(line, 'start').tokens
}
describe('word list syntax mode', () => {
@@ -25,14 +30,103 @@ describe('word list syntax mode', () => {
expect(tokenize('屏蔽词')).toEqual([{ type: 'word_list_block', value: '屏蔽词' }])
})
it('separates replaced and replacement fields without parsing their content', () => {
it('parses valid replacement parameters', () => {
expect(tokenize('旧名.* => 新名 {[tmdbid=123;type=tv]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名.*' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=123;type=tv]}' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '123' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'tv' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks invalid replacement parameter keys and values', () => {
expect(tokenize('旧名 => 新名 {[unknown=1;tmdbid=abc;type=anime;g=group;s=2;e=3]}')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 ' },
{ type: 'word_list_parameter_syntax', value: '{[' },
{ type: 'invalid.word-list', value: 'unknown' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '1' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'tmdbid' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'abc' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'type' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'invalid.word-list', value: 'anime' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'g' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: 'group' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 's' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '2' },
{ type: 'word_list_parameter_syntax', value: ';' },
{ type: 'word_list_parameter_key', value: 'e' },
{ type: 'word_list_parameter_syntax', value: '=' },
{ type: 'word_list_parameter_value', value: '3' },
{ type: 'word_list_parameter_syntax', value: ']}' },
])
})
it('marks an unknown replacement parameter key as invalid', () => {
expect(tokenize('旧名 => {[unknown=1]}')).toContainEqual({ type: 'invalid.word-list', value: 'unknown' })
})
it('marks visible syntax for missing or malformed replacement parameters', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123;broken;;]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123', 'broken;;'])
expect(tokenize('旧名 => {[]}')).toContainEqual({ type: 'invalid.word-list', value: '{[]}' })
})
it('marks the second semicolon when it creates an empty parameter', () => {
const semicolonTokens = tokenize('旧名 => {[tmdbid=1;;type=tv]}').filter(token => token.value.includes(';'))
expect(semicolonTokens).toEqual([{ type: 'invalid.word-list', value: ';;' }])
})
it('marks both sides of missing keys and values', () => {
const invalidTokenValues = tokenize('旧名 => {[tmdbid=;=123]}')
.filter(token => token.type === 'invalid.word-list')
.map(token => token.value)
expect(invalidTokenValues).toEqual(['tmdbid=', '=123'])
})
it('treats an unclosed parameter block as ordinary replacement text', () => {
expect(tokenize('旧名 => 新名 {[tmdbid=1')).toEqual([
{ type: 'word_list_replaced', value: '旧名' },
{ type: 'keyword.operator.word-list', value: ' => ' },
{ type: 'word_list_replacement', value: '新名 {[tmdbid=1' },
])
})
it('accepts every supported replacement parameter type', () => {
expect(
tokenize('旧名 => {[tmdbid=1;doubanid=2;bangumiid=3;anilistid=4;type=movie;g=group;s=5;e=6]}'),
).not.toContainEqual(expect.objectContaining({ type: 'invalid.word-list' }))
})
it('accepts season and episode number ranges', () => {
expect(tokenize('旧名 => {[s=1-2;e=3-5]}')).not.toContainEqual(
expect.objectContaining({ type: 'invalid.word-list' }),
)
})
it('separates front, back, and episode offset fields', () => {
expect(tokenize('第 <> 集 >> EP+1')).toEqual([
{ type: 'word_list_front', value: '第' },
@@ -59,4 +153,24 @@ describe('word list syntax mode', () => {
])
expect(tokenize('屏蔽词')[0].type).toBe('word_list_block')
})
it('highlights comment lines', () => {
expect(tokenize('# 这是一个注释')).toEqual([{ type: 'comment.word-list', value: '# 这是一个注释' }])
})
})
describe('word list plain mode', () => {
it('does not highlight comment lines', () => {
expect(tokenizePlain('# 这是一个注释')).toEqual([{ type: 'text', value: '# 这是一个注释' }])
})
it('does not highlight word list fields', () => {
expect(tokenizePlain('旧名 => 新名 && 第 <> 集 >> 2*EP-1')).toEqual([
{ type: 'text', value: '旧名 => 新名 && 第 <> 集 >> 2*EP-1' },
])
})
it('returns no tokens for empty lines', () => {
expect(tokenizePlain('')).toEqual([])
})
})
+232 -94
View File
@@ -530,6 +530,234 @@ function registerJinja2Mode() {
)
}
interface WordListToken {
type: string
value: string
}
const wordListReplacementParametersPattern = /\{\[([^\]]*)\]\}/g
const wordListUnsignedIntegerPattern = /^\d+$/
const wordListUnsignedIntegerOrRangePattern = /^\d+(?:-\d+)?$/
const wordListParameterTypes = {
tmdbid: 'uint',
doubanid: 'uint',
bangumiid: 'uint',
anilistid: 'uint',
type: 'media-type',
g: 'string',
s: 'uint-or-range',
e: 'uint-or-range',
} as const
function appendWordListToken(tokens: WordListToken[], type: string, value: string) {
if (!value) return
const previousToken = tokens.at(-1)
if (previousToken?.type === type) previousToken.value += value
else tokens.push({ type, value })
}
function isValidWordListParameterValue(key: keyof typeof wordListParameterTypes, value: string) {
switch (wordListParameterTypes[key]) {
case 'uint':
return wordListUnsignedIntegerPattern.test(value)
case 'uint-or-range':
return wordListUnsignedIntegerOrRangePattern.test(value)
case 'media-type':
return value === 'movie' || value === 'tv'
case 'string':
return value.length > 0
}
}
function tokenizeWordListParameters(parameters: string): WordListToken[] {
const tokens: WordListToken[] = []
const parameterList = parameters.split(';')
parameterList.forEach((parameter, index) => {
const isEmptyParameter = parameter.length === 0
if (index > 0) {
appendWordListToken(
tokens,
parameterList[index - 1] === '' || isEmptyParameter ? 'invalid.word-list' : 'word_list_parameter_syntax',
';',
)
}
if (isEmptyParameter) return
const equalsIndex = parameter.indexOf('=')
if (equalsIndex === -1) {
appendWordListToken(tokens, 'invalid.word-list', parameter)
return
}
const key = parameter.slice(0, equalsIndex)
const value = parameter.slice(equalsIndex + 1)
const hasKey = key.length > 0
const hasValue = value.length > 0
const isKnownKey = hasKey && Object.hasOwn(wordListParameterTypes, key)
appendWordListToken(tokens, isKnownKey && hasValue ? 'word_list_parameter_key' : 'invalid.word-list', key)
appendWordListToken(tokens, hasKey && hasValue ? 'word_list_parameter_syntax' : 'invalid.word-list', '=')
const isValidValue =
isKnownKey &&
!value.includes('=') &&
isValidWordListParameterValue(key as keyof typeof wordListParameterTypes, value)
appendWordListToken(
tokens,
isValidValue || (!isKnownKey && hasKey) ? 'word_list_parameter_value' : 'invalid.word-list',
value,
)
})
return tokens
}
function tokenizeWordListReplacement(replacement: string): WordListToken[] {
const tokens: WordListToken[] = []
let replacementStart = 0
for (const match of replacement.matchAll(wordListReplacementParametersPattern)) {
const parameterStart = match.index ?? 0
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart, parameterStart))
if (!match[1]) appendWordListToken(tokens, 'invalid.word-list', match[0])
else {
appendWordListToken(tokens, 'word_list_parameter_syntax', '{[')
tokenizeWordListParameters(match[1]).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'word_list_parameter_syntax', ']}')
}
replacementStart = parameterStart + match[0].length
}
appendWordListToken(tokens, 'word_list_replacement', replacement.slice(replacementStart))
return tokens
}
function splitWordListField(value: string, operator: string) {
let operatorIndex = value.indexOf(operator)
while (operatorIndex !== -1) {
const operatorEnd = operatorIndex + operator.length
if (value[operatorIndex - 1] === ' ' && value[operatorEnd] === ' ') {
let operatorStart = operatorIndex
while (value[operatorStart - 1] === ' ') operatorStart -= 1
let operatorWithSpacesEnd = operatorEnd
while (value[operatorWithSpacesEnd] === ' ') operatorWithSpacesEnd += 1
return {
before: value.slice(0, operatorStart),
operator: value.slice(operatorStart, operatorWithSpacesEnd),
after: value.slice(operatorWithSpacesEnd),
}
}
operatorIndex = value.indexOf(operator, operatorEnd)
}
}
function getWordListLineWhitespace(value: string) {
const leadingSpaceLength = value.length - value.trimStart().length
const trailingSpaceLength = value.length - value.trimEnd().length
return {
leadingSpace: value.slice(0, leadingSpaceLength),
content: value.slice(leadingSpaceLength, value.length - trailingSpaceLength),
trailingSpace: value.slice(value.length - trailingSpaceLength),
}
}
function tokenizeWordListReplacementLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
if (!replacementField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(replacementField.after).forEach(token =>
appendWordListToken(tokens, token.type, token.value),
)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function tokenizeCombinedWordListLine(value: string): WordListToken[] {
const { leadingSpace, content, trailingSpace } = getWordListLineWhitespace(value)
const replacementField = splitWordListField(content, '=>')
const frontField = replacementField && splitWordListField(replacementField.after, '&&')
const backField = frontField && splitWordListField(frontField.after, '<>')
const offsetField = backField && splitWordListField(backField.after, '>>')
if (!replacementField || !frontField || !backField || !offsetField) return [{ type: 'text', value }]
const tokens: WordListToken[] = []
appendWordListToken(tokens, 'text', leadingSpace)
appendWordListToken(tokens, 'word_list_replaced', replacementField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', replacementField.operator)
tokenizeWordListReplacement(frontField.before).forEach(token => appendWordListToken(tokens, token.type, token.value))
appendWordListToken(tokens, 'keyword.operator.word-list', frontField.operator)
appendWordListToken(tokens, 'word_list_front', backField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', backField.operator)
appendWordListToken(tokens, 'word_list_back', offsetField.before)
appendWordListToken(tokens, 'keyword.operator.word-list', offsetField.operator)
appendWordListToken(tokens, 'word_list_offset', offsetField.after)
appendWordListToken(tokens, 'text', trailingSpace)
return tokens
}
function buildWordListRules(syntax: boolean) {
if (!syntax) {
return {
start: [
{
token: 'empty_line',
regex: '^$',
},
{
defaultToken: 'text',
},
],
}
}
return {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
onMatch: tokenizeCombinedWordListLine,
},
{
token: 'text',
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
onMatch: tokenizeWordListReplacementLine,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
}
function registerWordListMode() {
aceModule.define?.(
'ace/mode/word_list_highlight_rules',
@@ -538,15 +766,8 @@ function registerWordListMode() {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
],
}
const WordListHighlightRules = function (this: any, options?: { syntax?: boolean }) {
this.$rules = buildWordListRules(options?.syntax === true)
this.normalizeRules()
}
@@ -564,9 +785,10 @@ function registerWordListMode() {
const TextMode = require('./text').Mode
const WordListHighlightRules = require('./word_list_highlight_rules').WordListHighlightRules
const Mode = function (this: any) {
const Mode = function (this: any, options?: { syntax?: boolean }) {
TextMode.call(this)
this.HighlightRules = WordListHighlightRules
this.$highlightRuleConfig = options || {}
}
oop.inherits(Mode, TextMode)
@@ -578,90 +800,6 @@ function registerWordListMode() {
exports.Mode = Mode
},
)
aceModule.define?.(
'ace/mode/word_list_syntax_highlight_rules',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextHighlightRules = require('./text_highlight_rules').TextHighlightRules
const WordListSyntaxHighlightRules = function (this: any) {
this.$rules = {
start: [
{
token: 'comment.word-list',
regex: /^#.*/,
},
{
token: [
'text',
'word_list_replaced',
'keyword.operator.word-list',
'word_list_replacement',
'keyword.operator.word-list',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +=> +)(.*?)( +&& +)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_replaced', 'keyword.operator.word-list', 'word_list_replacement', 'text'],
regex: /^(\s*)(.*?)( +=> +)(.*?)(\s*)$/,
},
{
token: [
'text',
'word_list_front',
'keyword.operator.word-list',
'word_list_back',
'keyword.operator.word-list',
'word_list_offset',
'text',
],
regex: /^(\s*)(.*?)( +<> +)(.*?)( +>> +)(.*?)(\s*)$/,
},
{
token: ['text', 'word_list_block', 'text'],
regex: /^(\s*)(\S(?:.*?\S)?)(\s*)$/,
},
],
}
this.normalizeRules()
}
oop.inherits(WordListSyntaxHighlightRules, TextHighlightRules)
exports.WordListSyntaxHighlightRules = WordListSyntaxHighlightRules
},
)
aceModule.define?.(
'ace/mode/word_list_syntax',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/word_list_syntax_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const WordListSyntaxHighlightRules = require('./word_list_syntax_highlight_rules').WordListSyntaxHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = WordListSyntaxHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/word_list_syntax'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
+6 -3
View File
@@ -1084,6 +1084,11 @@ export interface User {
nickname?: string
}
// 头像上传响应数据
export interface AvatarUploadData {
filename: string
}
// 通行密钥
export interface PassKey {
id: number
@@ -1889,9 +1894,7 @@ export interface RecognitionCacheItem {
key: string
// TMDB ID0 表示未识别
tmdb_id?: number
// 豆瓣 ID0 表示未识别
douban_id?: string | number
// 当前识别数据源对应的统一 ID
// 识别缓存对应的字符串 ID
recognition_id?: string
// 识别后的标题
title: string
+14 -4
View File
@@ -11,6 +11,7 @@ import { useI18n } from 'vue-i18n'
import AgentPetStage from './pet/AgentPetStage.vue'
import type { AgentPetActionName, AgentPetIntent } from './pet/types'
import { useAgentPetMachine } from './pet/useAgentPetMachine'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
interface AgentAssistantEntryBubble {
id: string
@@ -46,6 +47,8 @@ const props = withDefaults(
},
)
const ASSISTANT_PREVIEW_MAX_LENGTH = 480
const emit = defineEmits<{
open: []
}>()
@@ -163,6 +166,7 @@ const fabPositionStyle = computed(() => {
...fabPointerStyle.value,
'--agent-assistant-fab-x': `${position.x}px`,
'--agent-assistant-fab-y': `${position.y}px`,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.entry,
}
})
const fabBubblePlacement = ref<FabBubblePlacement>('top')
@@ -1012,6 +1016,15 @@ function scheduleFabBubbleRemoval(id: string, duration = FAB_NOTIFICATION_BUBBLE
function upsertFabBubble(bubble: AgentAssistantEntryBubble, options: { autoClose?: boolean; duration?: number } = {}) {
if (!props.active || !bubble.text) return
const existingIndex = fabBubbles.value.findIndex(item => item.id === bubble.id)
if (existingIndex >= 0) {
fabBubbles.value[existingIndex] = bubble
setFabDocked(false)
nextTick(scheduleFabBubblePositionUpdate)
if (options.autoClose) scheduleFabBubbleRemoval(bubble.id, options.duration)
return
}
const hadBubbles = hasFabBubbles.value
const wasDocked = fabDocked.value
const existingBubbles = fabBubbles.value.filter(item => item.id !== bubble.id)
@@ -1059,7 +1072,7 @@ function showAssistantReplyPreview(value: string) {
showBubble({
id: 'assistant-preview',
kind: 'assistant',
text: value,
text: value.slice(0, ASSISTANT_PREVIEW_MAX_LENGTH),
})
}
@@ -1570,9 +1583,6 @@ defineExpose({
.agent-assistant-fab {
position: fixed;
/* 保持机器人和提示气泡高于 Vuetify 弹窗(2400)及全局 Toast2500)。 */
z-index: 2600;
--agent-assistant-robot-outline: color-mix(in srgb, rgb(var(--v-theme-primary)) 72%, #090510 28%);
--agent-assistant-robot-outline-soft: color-mix(in srgb, rgb(var(--v-theme-primary)) 84%, #090510 16%);
--agent-assistant-robot-shell-start: color-mix(in srgb, rgb(var(--v-theme-primary)) 38%, white 62%);
+279 -81
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
import { useAuthStore, useUserStore } from '@/stores'
import { getCurrentLocale } from '@/plugins/i18n'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
import AgentMarkdownContent from './AgentMarkdownContent.vue'
type AgentMessageRole = 'user' | 'assistant'
type AgentMessageStatus = 'idle' | 'streaming' | 'done' | 'error'
@@ -19,6 +19,21 @@ interface AgentToolCall {
status: 'running' | 'done'
}
interface AgentMessageTextSegment {
type: 'text'
content: string
}
interface AgentMessageToolSegment {
type: 'tool'
toolIndex: number
}
type AgentMessageSegment = AgentMessageTextSegment | AgentMessageToolSegment
type AgentRenderableMessageSegment =
(AgentMessageTextSegment & { key: string }) | { type: 'tool'; key: string; tool: AgentToolCall }
interface AgentMessageAttachment {
kind: AgentAttachmentKind
url: string
@@ -65,6 +80,7 @@ interface AgentChatMessage {
createdAt: number
status: AgentMessageStatus
tools: AgentToolCall[]
segments: AgentMessageSegment[]
attachments: AgentMessageAttachment[]
choices: AgentChoiceCard[]
choice_selection?: AgentChoiceSelection
@@ -198,6 +214,7 @@ const messages = ref<AgentChatMessage[]>([])
const historySessions = ref<AgentSessionHistoryItem[]>([])
const sessionId = ref('')
const sending = ref(false)
const isComposing = ref(false)
const streamError = ref('')
const historyMenuOpen = ref(false)
const messageListRef = ref<HTMLElement | null>(null)
@@ -223,25 +240,16 @@ let recordingChunks: BlobPart[] = []
let messageScrollFrame: number | null = null
let pendingMessageScrollToBottom = false
let streamPersistTimer: number | null = null
let streamPersistLastRunAt = 0
let messageScrollerShouldFollow = true
let streamDeltaFrame: number | null = null
let pendingStreamDelta = ''
let pendingStreamDeltaMessage: AgentChatMessage | null = null
let userAbortRequested = false
let streamRecoveryAbortRequested = false
let streamRecoveryTimer: number | null = null
let activeStreamStartedAt = 0
const md = new MarkdownIt({
html: true,
breaks: true,
linkify: true,
typographer: true,
})
md.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// 汇总实时请求与后台恢复状态,保证恢复期间仍展示处理中并锁定会话操作。
const isBusy = computed(() => sending.value || Boolean(pendingStreamRecovery.value))
const canSend = computed(
@@ -296,6 +304,7 @@ const isOpen = computed({
})
const drawerStyle = computed(() => ({
'--agent-assistant-panel-width': drawerWidth.value,
zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.panel,
}))
// 创建前端展示用的临时 ID。
@@ -515,26 +524,57 @@ function normalizeChoiceSelectionMessages(sessionMessages: AgentChatMessage[]) {
return sessionMessages
}
// 规范化历史消息,补齐附件、工具和选择项等可选数组
// 规范化消息的有序片段;旧历史按原来的工具在前、文本在后布局回退
function normalizeMessageSegments(value: unknown, content: string, tools: AgentToolCall[]) {
const normalizedSegments: AgentMessageSegment[] = []
if (Array.isArray(value)) {
value.forEach(rawSegment => {
if (!rawSegment || typeof rawSegment !== 'object' || Array.isArray(rawSegment)) return
const segment = rawSegment as Record<string, unknown>
if (segment.type === 'text' && typeof segment.content === 'string' && segment.content.trim()) {
normalizedSegments.push({ type: 'text', content: segment.content })
return
}
const toolIndex = Number(segment.toolIndex ?? segment.tool_index)
if (segment.type === 'tool' && Number.isInteger(toolIndex) && toolIndex >= 0 && toolIndex < tools.length) {
normalizedSegments.push({ type: 'tool', toolIndex })
}
})
}
if (normalizedSegments.length) return normalizedSegments
tools.forEach((_tool, toolIndex) => normalizedSegments.push({ type: 'tool', toolIndex }))
if (content.trim()) normalizedSegments.push({ type: 'text', content })
return normalizedSegments
}
// 规范化历史消息,补齐附件、工具、有序片段和选择项等可选数组。
function normalizeStoredMessages(value: unknown) {
if (!Array.isArray(value)) return []
const normalizedMessages = value.slice(-MAX_PERSISTED_MESSAGES).map(rawMessage => {
const message = rawMessage && typeof rawMessage === 'object' ? (rawMessage as Record<string, unknown>) : {}
const role = message.role === 'assistant' ? 'assistant' : 'user'
const content = typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content)
const tools = Array.isArray(message.tools) ? (message.tools as AgentToolCall[]) : []
return {
...message,
id: stringifyChoiceField(message.id) || createId(role),
role,
content: typeof message.content === 'string' ? message.content : stringifyChoiceField(message.content),
content,
createdAt: Number(message.createdAt) || Number(message.created_at) || Date.now(),
status: normalizeMessageStatus(message.status),
attachments: Array.isArray(message.attachments) ? message.attachments : [],
choices: Array.isArray(message.choices)
? (message.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
: [],
tools: Array.isArray(message.tools) ? message.tools : [],
tools,
segments: normalizeMessageSegments(message.segments, content, tools),
choice_selection: normalizeChoiceSelection(message.choice_selection || message.choiceSelection),
} as AgentChatMessage
})
@@ -811,7 +851,7 @@ function failStreamRecovery() {
.find(message => message.role === 'assistant' && message.status === 'streaming')
if (assistantMessage) {
assistantMessage.status = 'error'
assistantMessage.content ||= t('agentAssistant.recoveryFailed')
if (!assistantMessage.content) appendAssistantTextSegment(assistantMessage, t('agentAssistant.recoveryFailed'))
markToolsDone(assistantMessage)
refreshMessageList()
} else {
@@ -1056,12 +1096,6 @@ function persistState(options: { syncHistory?: boolean } = {}) {
if (syncHistory) upsertCurrentSessionHistory()
}
// 渲染助手消息中的 Markdown 文本。
function renderMarkdown(value: string) {
if (!value) return ''
return md.render(value)
}
// 拼接后端 API 地址。
function resolveApiUrl(path: string) {
const baseUrl = import.meta.env.VITE_API_BASE_URL || '/'
@@ -1106,6 +1140,11 @@ function isMessageScrollerNearBottom() {
return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= MESSAGE_SCROLL_FOLLOW_THRESHOLD
}
// 只在滚动事件中更新自动跟随意图,避免每个流式事件触发布局读取。
function handleMessageScrollerScroll() {
messageScrollerShouldFollow = isMessageScrollerNearBottom()
}
// 合并滚动更新请求,降低流式输出时的布局测量频率。
function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
const { toBottom = false } = options
@@ -1125,6 +1164,7 @@ function scheduleMessageScrollerUpdate(options: { toBottom?: boolean } = {}) {
// 将消息列表滚动到底部。
function scrollToBottom(options: { smooth?: boolean } = {}) {
const { smooth = false } = options
messageScrollerShouldFollow = true
nextTick(() => {
const scroller = getMessageScrollerElement()
if (!scroller) return
@@ -1168,13 +1208,17 @@ function clearMessageScrollFrame() {
pendingMessageScrollToBottom = false
}
// 延迟持久化流式消息,避免每个 token 都写入本地存储
// 流式期间至多每秒保存一次轻量当前态,终态再同步完整历史
function scheduleStreamPersist() {
clearStreamPersistTimer()
if (streamPersistTimer !== null) return
const elapsed = Date.now() - streamPersistLastRunAt
const delay = Math.max(0, STREAM_STATE_PERSIST_DELAY - elapsed)
streamPersistTimer = window.setTimeout(() => {
persistState()
streamPersistTimer = null
}, STREAM_STATE_PERSIST_DELAY)
streamPersistLastRunAt = Date.now()
persistState({ syncHistory: false })
}, delay)
}
// 同步输入框高度,使多行输入不撑破底部布局。
@@ -1210,6 +1254,7 @@ function addMessage(
attachments,
choices: [],
tools: [],
segments: role === 'assistant' && content ? [{ type: 'text', content }] : [],
choice_selection: choiceSelection,
}
messages.value.push(message)
@@ -1225,6 +1270,45 @@ function normalizeToolMessage(message: string) {
return message.replace(/^=>\s*/, '').trim()
}
// 解析非啰嗦模式的工具汇总,供相邻工具状态按类别累计次数。
function parseToolSummary(message: string) {
const summaryMatch = message.match(/^(.+)$/)
if (!summaryMatch) return null
const parts = summaryMatch[1].split('').map(part => {
const countMatch = part.trim().match(/^(.*?\D)(\d+)(\D.*)$/)
if (!countMatch) return null
return {
prefix: countMatch[1],
count: Number(countMatch[2]),
suffix: countMatch[3],
}
})
return parts.every(Boolean) ? (parts as Array<{ prefix: string; count: number; suffix: string }>) : null
}
// 仅合并相邻的非啰嗦工具汇总;正文或具体工具提示会自然终止当前聚合组。
function mergeToolSummaries(currentMessage: string, nextMessage: string) {
const currentParts = parseToolSummary(currentMessage)
const nextParts = parseToolSummary(nextMessage)
if (!currentParts || !nextParts) return null
const mergedParts = currentParts.map(part => ({ ...part }))
const partIndexes = new Map(mergedParts.map((part, index) => [`${part.prefix}\u0000${part.suffix}`, index]))
nextParts.forEach(part => {
const key = `${part.prefix}\u0000${part.suffix}`
const existingIndex = partIndexes.get(key)
if (existingIndex === undefined) {
partIndexes.set(key, mergedParts.length)
mergedParts.push({ ...part })
return
}
mergedParts[existingIndex].count += part.count
})
return `${mergedParts.map(part => `${part.prefix}${part.count}${part.suffix}`).join('')}`
}
// 将当前消息里的运行中工具标记为完成。
function markToolsDone(message: AgentChatMessage) {
message.tools.forEach(tool => {
@@ -1232,11 +1316,58 @@ function markToolsDone(message: AgentChatMessage) {
})
}
// 追加助手文本,并只合并紧邻的文本片段以保留工具事件边界。
function appendAssistantTextSegment(message: AgentChatMessage, content: string) {
if (!content) return
message.content += content
const lastSegment = message.segments.at(-1)
if (lastSegment?.type === 'text') {
lastSegment.content += content
} else if (content.trim()) {
message.segments.push({ type: 'text', content })
}
}
// 替换助手文本但保留工具片段,用于无法继续流式处理时显示错误。
function replaceAssistantTextSegments(message: AgentChatMessage, content: string) {
message.content = content
message.segments = message.segments.filter(segment => segment.type === 'tool')
if (content.trim()) message.segments.push({ type: 'text', content })
}
// 按 SSE 事件顺序渲染文本与工具,只跳过无法产生可见内容的空白文本。
function getRenderableMessageSegments(message: AgentChatMessage): AgentRenderableMessageSegment[] {
return message.segments.reduce<AgentRenderableMessageSegment[]>((renderableSegments, segment, index) => {
if (segment.type === 'text') {
if (segment.content.trim()) renderableSegments.push({ ...segment, key: `text-${index}` })
return renderableSegments
}
const tool = message.tools[segment.toolIndex]
if (tool) {
const previousSegment = renderableSegments.at(-1)
const mergedMessage =
previousSegment?.type === 'tool' ? mergeToolSummaries(previousSegment.tool.message, tool.message) : null
if (previousSegment?.type === 'tool' && mergedMessage) {
previousSegment.tool = {
...previousSegment.tool,
message: mergedMessage,
status: previousSegment.tool.status === 'running' || tool.status === 'running' ? 'running' : 'done',
}
} else {
renderableSegments.push({ type: 'tool', key: `tool-${tool.id}`, tool })
}
}
return renderableSegments
}, [])
}
// 判断消息是否没有任何可展示内容,可用于清理编辑回调产生的占位回复。
function isEmptyAssistantMessage(message: AgentChatMessage) {
return (
message.role === 'assistant' &&
!message.content &&
!message.content.trim() &&
message.attachments.length === 0 &&
message.choices.length === 0 &&
message.tools.length === 0
@@ -1255,6 +1386,7 @@ function applyMessageUpdate(event: AgentStreamEvent) {
message.content = typeof target?.content === 'string' ? target.content : ''
message.attachments = Array.isArray(target?.attachments) ? target.attachments : []
message.tools = Array.isArray(target?.tools) ? target.tools : []
message.segments = normalizeMessageSegments(target?.segments, message.content, message.tools)
message.choices = Array.isArray(target?.choices)
? (target.choices.map(normalizeChoiceCard).filter(Boolean) as AgentChoiceCard[])
: []
@@ -1266,11 +1398,9 @@ function applyMessageUpdate(event: AgentStreamEvent) {
// 将单个 SSE 事件应用到正在流式输出的助手消息。
function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
const shouldFollowBottom = isMessageScrollerNearBottom()
switch (event.type) {
case 'delta':
assistantMessage.content += event.content || ''
appendAssistantTextSegment(assistantMessage, event.content || '')
emit('assistant-preview', assistantMessage.content)
break
case 'tool':
@@ -1280,6 +1410,7 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
message: normalizeToolMessage(event.message || ''),
status: 'running',
})
assistantMessage.segments.push({ type: 'tool', toolIndex: assistantMessage.tools.length - 1 })
break
case 'attachment':
if (event.attachment?.url) {
@@ -1306,7 +1437,9 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
case 'error':
assistantMessage.status = 'error'
// 后端流式错误已经以 AI 消息展示,避免底部提示条重复且持续占位。
assistantMessage.content ||= event.message_i18n || event.message || t('agentAssistant.error')
if (!assistantMessage.content) {
appendAssistantTextSegment(assistantMessage, event.message_i18n || event.message || t('agentAssistant.error'))
}
emit('assistant-preview', assistantMessage.content)
markToolsDone(assistantMessage)
break
@@ -1322,10 +1455,54 @@ function applyStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMe
scheduleStreamPersist()
nextTick(() => {
scheduleMessageScrollerUpdate({ toBottom: shouldFollowBottom })
scheduleMessageScrollerUpdate({ toBottom: messageScrollerShouldFollow })
})
}
// 将同一条助手消息的连续文本增量合并到一个动画帧,语义事件到来前会同步冲刷。
function flushPendingStreamDelta() {
if (streamDeltaFrame !== null) {
window.cancelAnimationFrame(streamDeltaFrame)
streamDeltaFrame = null
}
if (!pendingStreamDeltaMessage || !pendingStreamDelta) return
const assistantMessage = pendingStreamDeltaMessage
const content = pendingStreamDelta
pendingStreamDeltaMessage = null
pendingStreamDelta = ''
applyStreamEvent({ type: 'delta', content }, assistantMessage)
}
function clearPendingStreamDelta() {
if (streamDeltaFrame !== null) window.cancelAnimationFrame(streamDeltaFrame)
streamDeltaFrame = null
pendingStreamDeltaMessage = null
pendingStreamDelta = ''
}
function schedulePendingStreamDeltaFlush() {
if (streamDeltaFrame !== null) return
streamDeltaFrame = window.requestAnimationFrame(() => {
streamDeltaFrame = null
flushPendingStreamDelta()
})
}
function queueStreamEvent(event: AgentStreamEvent, assistantMessage: AgentChatMessage) {
if (event.type !== 'delta') {
flushPendingStreamDelta()
applyStreamEvent(event, assistantMessage)
return
}
if (pendingStreamDeltaMessage && pendingStreamDeltaMessage !== assistantMessage) flushPendingStreamDelta()
pendingStreamDeltaMessage = assistantMessage
pendingStreamDelta += event.content || ''
schedulePendingStreamDeltaFlush()
}
// 解析一个 SSE 数据块。
function parseSseBlock(block: string) {
const data = block
@@ -1353,26 +1530,30 @@ async function readAgentStream(response: Response, assistantMessage: AgentChatMe
const consumeEvent = (event: AgentStreamEvent | null) => {
if (!event) return
applyStreamEvent(event, assistantMessage)
queueStreamEvent(event, assistantMessage)
if (event.type === 'done' || event.type === 'error') receivedTerminalEvent = true
}
while (true) {
const { value, done } = await reader.read()
if (done) break
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split(/\n\n/)
buffer = blocks.pop() || ''
buffer += decoder.decode(value, { stream: true })
const blocks = buffer.split(/\r?\n\r?\n/)
buffer = blocks.pop() || ''
for (const block of blocks) {
consumeEvent(parseSseBlock(block))
for (const block of blocks) {
consumeEvent(parseSseBlock(block))
}
}
}
buffer += decoder.decode()
if (buffer.trim()) {
consumeEvent(parseSseBlock(buffer))
buffer += decoder.decode()
if (buffer.trim()) {
consumeEvent(parseSseBlock(buffer))
}
} finally {
flushPendingStreamDelta()
}
return { receivedTerminalEvent }
@@ -1641,7 +1822,7 @@ async function streamAgentMessage(
}
assistantMessage.status = 'error'
assistantMessage.content = error?.message || t('agentAssistant.error')
replaceAssistantTextSegments(assistantMessage, error?.message || t('agentAssistant.error'))
markToolsDone(assistantMessage)
refreshMessageList()
} finally {
@@ -2052,11 +2233,19 @@ function handlePageShow() {
// 处理输入框回车发送。
function handleInputKeydown(event: KeyboardEvent) {
if (event.key !== 'Enter' || event.shiftKey) return
if (event.key !== 'Enter' || event.shiftKey || isComposing.value || event.isComposing || event.keyCode === 229) return
event.preventDefault()
sendMessage()
}
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
isComposing.value = false
}
watch(isOpen, syncAgentAssistantOpenState, { immediate: true })
watch(drawerWidth, () => {
if (isOpen.value) syncAgentAssistantOpenState(true)
@@ -2084,6 +2273,7 @@ onScopeDispose(clearPendingAttachments)
onScopeDispose(cancelVoiceRecording)
onScopeDispose(clearMessageScrollFrame)
onScopeDispose(clearStreamPersistTimer)
onScopeDispose(clearPendingStreamDelta)
onScopeDispose(clearStreamRecoveryTimer)
onScopeDispose(() => {
if (typeof window === 'undefined') return
@@ -2130,11 +2320,11 @@ onScopeDispose(() => {
<VMenu
v-model="historyMenuOpen"
:close-on-content-click="false"
content-class="agent-assistant-history-overlay"
location="bottom end"
offset="8"
max-width="360"
:z-index="2603"
:style="{ zIndex: AGENT_ASSISTANT_LAYER_Z_INDEX.overlay }"
:z-index="AGENT_ASSISTANT_LAYER_Z_INDEX.overlay"
>
<template #activator="{ props }">
<IconBtn v-bind="props" :title="t('agentAssistant.history')" :aria-label="t('agentAssistant.history')">
@@ -2224,6 +2414,7 @@ onScopeDispose(() => {
ref="messageListRef"
class="agent-assistant-messages"
:class="{ 'agent-assistant-messages--has-content': hasMessages }"
@scroll.passive="handleMessageScrollerScroll"
>
<div class="agent-assistant-messages__content">
<div v-if="!hasMessages" class="agent-assistant-empty">
@@ -2245,31 +2436,41 @@ onScopeDispose(() => {
<span>{{ message.role === 'user' ? currentUserName : t('agentAssistant.assistant') }}</span>
</div>
<div v-if="message.tools.length" class="agent-assistant-tools">
<div v-for="tool in message.tools" :key="tool.id" class="agent-assistant-tool">
<VIcon
:icon="
tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
<div
v-if="message.role === 'assistant' && (message.tools.length || message.content.trim())"
class="agent-assistant-segments"
>
<template v-for="segment in getRenderableMessageSegments(message)" :key="segment.key">
<AgentMarkdownContent
v-if="segment.type === 'text'"
:content="segment.content"
:streaming="message.status === 'streaming'"
/>
<span>{{ tool.message }}</span>
</div>
<div v-else class="agent-assistant-tool">
<VIcon
:icon="
segment.tool.status === 'running' && message.status === 'streaming'
? 'line-md:loading-twotone-loop'
: 'mdi-check-circle-outline'
"
size="16"
/>
<span>{{ segment.tool.message }}</span>
</div>
</template>
</div>
<div
v-if="message.content"
class="agent-assistant-message__bubble markdown-body"
v-html="renderMarkdown(message.content)"
<AgentMarkdownContent
v-else-if="message.content"
:content="message.content"
:streaming="message.status === 'streaming'"
/>
<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 class="agent-assistant-choice__bubble">
<div v-if="choice.title" class="agent-assistant-choice__title">{{ choice.title }}</div>
<div class="agent-assistant-choice__prompt markdown-body" v-html="renderMarkdown(choice.prompt)" />
<AgentMarkdownContent :content="choice.prompt" variant="choice" />
<div v-if="choice.status === 'selected'" class="agent-assistant-choice__selected">
<VIcon icon="mdi-check-circle-outline" size="16" />
<span>{{
@@ -2363,6 +2564,7 @@ onScopeDispose(() => {
<div
v-if="
!message.content &&
!message.segments.length &&
!message.attachments.length &&
!message.choices.length &&
message.status === 'streaming'
@@ -2454,6 +2656,8 @@ onScopeDispose(() => {
:placeholder="inputPlaceholder"
@input="handleInputChange"
@keydown="handleInputKeydown"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
/>
<IconBtn
class="agent-assistant-record agent-assistant-surface-btn"
@@ -2488,21 +2692,12 @@ onScopeDispose(() => {
</aside>
</template>
<style lang="scss">
.agent-assistant-history-overlay {
z-index: 2603 !important;
}
</style>
<style lang="scss" scoped>
/* stylelint-disable selector-pseudo-class-no-unknown */
/* stylelint-disable no-descending-specificity */
.agent-assistant-panel {
position: fixed;
/* Agent 会话层保持高于入口(2600)和业务弹窗,同时低于自身弹出菜单。 */
z-index: 2601;
overflow: hidden;
background: rgb(var(--v-theme-surface));
@@ -2941,11 +3136,14 @@ onScopeDispose(() => {
background: var(--agent-assistant-assistant-bg);
}
.agent-assistant-tools {
.agent-assistant-segments {
display: grid;
gap: 0.4rem;
gap: 0.5rem;
inline-size: min(100%, 34rem);
margin-block-end: 0.5rem;
}
.agent-assistant-segments .agent-assistant-message__bubble {
inline-size: 100%;
}
.agent-assistant-tool {
+56 -15
View File
@@ -2,6 +2,7 @@
import AgentAssistantEntry from './AgentAssistantEntry.vue'
import AgentAssistantPanel from './AgentAssistantPanel.vue'
import { useAppActivityLifecycle } from '@/composables/useAppActivityLifecycle'
import { useTheme } from 'vuetify'
type AgentAssistantEntryRef = InstanceType<typeof AgentAssistantEntry>
@@ -9,33 +10,73 @@ const panelOpen = ref(false)
const thinking = ref(false)
const entryRef = ref<AgentAssistantEntryRef | null>(null)
const { allowsDecorativeMotion } = useAppActivityLifecycle()
const { themeClasses } = useTheme()
const ASSISTANT_PREVIEW_INTERVAL = 125
let assistantPreviewTimer: number | null = null
let assistantPreviewPendingValue = ''
let assistantPreviewLastShownAt = 0
let assistantPreviewHasShown = false
function clearAssistantPreviewTimer() {
if (assistantPreviewTimer === null) return
window.clearTimeout(assistantPreviewTimer)
assistantPreviewTimer = null
}
function showPendingAssistantPreview() {
assistantPreviewTimer = null
if (panelOpen.value || !assistantPreviewPendingValue) return
entryRef.value?.showAssistantReplyPreview(assistantPreviewPendingValue)
assistantPreviewLastShownAt = performance.now()
assistantPreviewHasShown = true
}
// 打开 Agent 面板并清空入口预览气泡。
function openPanel() {
panelOpen.value = true
assistantPreviewPendingValue = ''
clearAssistantPreviewTimer()
entryRef.value?.clearBubbles()
}
// 面板关闭时展示助手回复预览
// 面板关闭时限制预览更新频率,避免每个流式 token 都触发气泡布局
function handleAssistantPreview(value: string) {
if (panelOpen.value) return
entryRef.value?.showAssistantReplyPreview(value)
assistantPreviewPendingValue = value
const elapsed = performance.now() - assistantPreviewLastShownAt
if (!assistantPreviewHasShown || elapsed >= ASSISTANT_PREVIEW_INTERVAL) {
clearAssistantPreviewTimer()
showPendingAssistantPreview()
return
}
if (assistantPreviewTimer !== null) return
assistantPreviewTimer = window.setTimeout(showPendingAssistantPreview, ASSISTANT_PREVIEW_INTERVAL - elapsed)
}
onScopeDispose(clearAssistantPreviewTimer)
</script>
<template>
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
<!-- 脱离 .v-application 的层叠上下文确保弹窗打开时入口消息气泡和面板仍在最上层 -->
<Teleport to="body">
<div class="agent-assistant-layer" :class="themeClasses">
<AgentAssistantEntry
ref="entryRef"
:active="!panelOpen"
:motion-active="allowsDecorativeMotion"
:thinking="thinking"
@open="openPanel"
/>
<AgentAssistantPanel
v-model="panelOpen"
:motion-active="allowsDecorativeMotion"
@assistant-preview="handleAssistantPreview"
@thinking-change="thinking = $event"
/>
</div>
</Teleport>
</template>
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { renderAgentMarkdown } from '@/utils/agentMarkdown'
const props = withDefaults(
defineProps<{
content: string
streaming?: boolean
variant?: 'choice' | 'message'
}>(),
{
streaming: false,
variant: 'message',
},
)
const STREAM_MARKDOWN_RENDER_INTERVAL = 96
const renderedHtml = shallowRef('')
let renderTimer: number | null = null
let lastRenderedAt = 0
let hasRendered = false
function clearRenderTimer() {
if (renderTimer === null) return
window.clearTimeout(renderTimer)
renderTimer = null
}
// 流式阶段限制 Markdown 全量解析频率;结束时立即渲染最终内容。
function renderContent(immediate = false) {
const now = performance.now()
const elapsed = now - lastRenderedAt
if (immediate || !hasRendered || elapsed >= STREAM_MARKDOWN_RENDER_INTERVAL) {
clearRenderTimer()
renderedHtml.value = renderAgentMarkdown(props.content)
lastRenderedAt = now
hasRendered = true
return
}
if (renderTimer !== null) return
renderTimer = window.setTimeout(() => {
renderTimer = null
renderedHtml.value = renderAgentMarkdown(props.content)
lastRenderedAt = performance.now()
hasRendered = true
}, STREAM_MARKDOWN_RENDER_INTERVAL - elapsed)
}
watch(
() => props.content,
() => renderContent(!props.streaming),
{ immediate: true },
)
watch(
() => props.streaming,
streaming => {
if (!streaming) renderContent(true)
},
)
onScopeDispose(clearRenderTimer)
</script>
<template>
<div
v-if="renderedHtml"
class="markdown-body"
:class="variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'"
v-html="renderedHtml"
/>
</template>
@@ -68,4 +68,44 @@ describe('AgentAssistantEntry lifecycle motion', () => {
wrapper.unmount()
})
it('updates an existing assistant preview without recreating its resize observer', async () => {
const observe = vi.fn()
const disconnect = vi.fn()
const resizeObserverConstructor = vi.fn()
vi.stubGlobal(
'ResizeObserver',
class {
constructor() {
resizeObserverConstructor()
}
observe = observe
disconnect = disconnect
},
)
const wrapper = shallowMount(AgentAssistantEntry, {
global: {
stubs: {
AgentPetStage: true,
VIcon: true,
},
},
props: {
active: true,
motionActive: true,
},
})
wrapper.vm.showAssistantReplyPreview('第一段')
await nextTick()
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
wrapper.vm.showAssistantReplyPreview('第二段')
await nextTick()
expect(resizeObserverConstructor).toHaveBeenCalledTimes(1)
expect(wrapper.find('.agent-assistant-fab__bubble').text()).toContain('第二段')
wrapper.unmount()
})
})
@@ -27,6 +27,12 @@ interface MockServerSession {
messages: Array<Record<string, unknown>>
}
const agentMarkdownContentStub = {
props: ['content', 'variant'],
template:
"<div :class=\"variant === 'choice' ? 'agent-assistant-choice__prompt' : 'agent-assistant-message__bubble'\">{{ content }}</div>",
}
// 构造符合 Agent 标准响应包装的 fetch 返回值。
function createAgentResponse(data: unknown) {
return {
@@ -111,6 +117,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
@@ -203,6 +210,7 @@ describe('AgentAssistantPanel stream recovery', () => {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
@@ -237,4 +245,238 @@ describe('AgentAssistantPanel stream recovery', () => {
wrapper.unmount()
})
it('does not send when Enter confirms an IME composition', async () => {
const fetchMock = vi.fn(async () => createAgentResponse([]))
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await flushPromises()
fetchMock.mockClear()
await textarea.setValue('搜索电影')
await textarea.trigger('compositionstart')
await textarea.trigger('keydown', { key: 'Enter', isComposing: true })
await textarea.trigger('compositionend')
await flushPromises()
expect(fetchMock).not.toHaveBeenCalled()
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(fetchMock).toHaveBeenCalled()
wrapper.unmount()
})
it('renders interleaved assistant text and tool events in their SSE order', async () => {
const serverSessionId = 'web-agent:ordered-segments'
const streamEvents = [
{ type: 'start', session_id: serverSessionId },
{ type: 'delta', content: '先检查服务器。' },
{ type: 'tool', message: '(执行了 1 条命令)' },
{ type: 'delta', content: '检查完成,没有发现错误。' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
const textarea = wrapper.find('textarea')
await textarea.setValue('检查服务器')
await textarea.trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toContain('先检查服务器。')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
expect(renderedSegments[1].text()).toContain('执行了 1 条命令')
expect(renderedSegments[2].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[2].text()).toContain('检查完成,没有发现错误。')
const saveCall = fetchMock.mock.calls.find(([input]) => String(input).includes('/display'))
const savedMessages = JSON.parse(String(saveCall?.[1]?.body || '{}')).messages as Array<Record<string, unknown>>
expect(savedMessages.at(-1)).toMatchObject({
content: '先检查服务器。检查完成,没有发现错误。',
segments: [
{ type: 'text', content: '先检查服务器。' },
{ type: 'tool', toolIndex: 0 },
{ type: 'text', content: '检查完成,没有发现错误。' },
],
})
wrapper.unmount()
})
it('does not render an empty text bubble for trailing whitespace after a tool summary', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:trailing-whitespace' },
{ type: 'delta', content: '最终结论' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '\n\n' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('检查配置')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(2)
expect(renderedSegments[0].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[0].text()).toBe('最终结论')
expect(renderedSegments[1].classes()).toContain('agent-assistant-tool')
wrapper.unmount()
})
it('aggregates adjacent non-verbose tools and starts a new group after text', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:tool-groups' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'tool', message: '(查看了 1 个目录)' },
{ type: 'tool', message: '(查询了 1 次数据)' },
{ type: 'delta', content: '继续分析。' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'tool', message: '(读取了 1 个文件)' },
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('分析插件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
const renderedSegments = wrapper.findAll('.agent-assistant-segments > *')
expect(renderedSegments).toHaveLength(3)
expect(renderedSegments[0].text()).toContain('查询了 2 次数据,查看了 1 个目录')
expect(renderedSegments[1].classes()).toContain('agent-assistant-message__bubble')
expect(renderedSegments[1].text()).toBe('继续分析。')
expect(renderedSegments[2].text()).toContain('读取了 2 个文件')
wrapper.unmount()
})
it('coalesces consecutive text deltas into one UI update before a terminal event', async () => {
const streamEvents = [
{ type: 'start', session_id: 'web-agent:coalesced' },
...Array.from({ length: 100 }, (_item, index) => ({ type: 'delta', content: String(index % 10) })),
{ type: 'done' },
]
const streamBody = streamEvents.map(event => `data: ${JSON.stringify(event)}\n\n`).join('')
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
if (String(input).endsWith('/message/agent/stream') && init?.method === 'POST') {
return new Response(streamBody, {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})
}
return createAgentResponse([])
}),
)
const wrapper = shallowMount(AgentAssistantPanel, {
props: { modelValue: true },
global: {
stubs: {
AgentMarkdownContent: agentMarkdownContentStub,
IconBtn: { template: '<button><slot /></button>' },
PerfectScrollbar: { template: '<div><slot /></div>' },
VIcon: true,
},
},
})
await wrapper.find('textarea').setValue('测试突发事件')
await wrapper.find('textarea').trigger('keydown', { key: 'Enter' })
await flushPromises()
expect(wrapper.emitted('assistant-preview')).toHaveLength(1)
expect(wrapper.find('.agent-assistant-message--assistant .agent-assistant-message__bubble').text()).toBe(
Array.from({ length: 100 }, (_item, index) => String(index % 10)).join(''),
)
wrapper.unmount()
})
})
@@ -0,0 +1,94 @@
import { mount } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import AgentAssistantWidget from '@/components/agent/AgentAssistantWidget.vue'
import { AGENT_ASSISTANT_LAYER_Z_INDEX } from '@/constants/agentAssistant'
vi.mock('vuetify', () => ({
useTheme: () => ({ themeClasses: ref('v-theme--test') }),
}))
vi.mock('@/composables/useAppActivityLifecycle', () => ({
useAppActivityLifecycle: () => ({ allowsDecorativeMotion: ref(true) }),
}))
describe('AgentAssistantWidget layering', () => {
afterEach(() => {
vi.useRealTimers()
document.body
.querySelectorAll('.agent-assistant-layer, .agent-assistant-test-host')
.forEach(element => element.remove())
})
it('teleports the assistant outside the application stacking context while preserving the active theme', () => {
const host = document.createElement('div')
host.className = 'agent-assistant-test-host v-application'
document.body.append(host)
const wrapper = mount(AgentAssistantWidget, {
attachTo: host,
global: {
stubs: {
AgentAssistantEntry: { template: '<div data-agent-assistant-entry />' },
AgentAssistantPanel: { template: '<div data-agent-assistant-panel />' },
},
},
})
const layer = document.body.querySelector(':scope > .agent-assistant-layer')
expect(layer).not.toBeNull()
expect(host.contains(layer)).toBe(false)
expect(layer).toHaveClass('v-theme--test')
expect(layer?.querySelector('[data-agent-assistant-entry]')).not.toBeNull()
expect(layer?.querySelector('[data-agent-assistant-panel]')).not.toBeNull()
wrapper.unmount()
})
it('reserves the highest CSS stacking levels in assistant display order', () => {
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.entry).toBe(2_147_483_645)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.panel).toBe(2_147_483_646)
expect(AGENT_ASSISTANT_LAYER_Z_INDEX.overlay).toBe(2_147_483_647)
})
it('limits closed-panel assistant preview updates and keeps the latest text', async () => {
vi.useFakeTimers()
const showAssistantReplyPreview = vi.fn()
const entryStub = defineComponent({
setup(_props, { expose }) {
expose({ clearBubbles: vi.fn(), showAssistantReplyPreview })
return () => h('div', { 'data-agent-assistant-entry': '' })
},
})
const panelStub = defineComponent({
emits: ['assistant-preview', 'thinking-change', 'update:modelValue'],
setup() {
return () => h('div', { 'data-agent-assistant-panel': '' })
},
})
const wrapper = mount(AgentAssistantWidget, {
global: {
stubs: {
AgentAssistantEntry: entryStub,
AgentAssistantPanel: panelStub,
},
},
})
const panel = wrapper.findComponent(panelStub)
panel.vm.$emit('assistant-preview', '第一段')
panel.vm.$emit('assistant-preview', '第二段')
panel.vm.$emit('assistant-preview', '最终预览')
await nextTick()
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(1)
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('第一段')
await vi.advanceTimersByTimeAsync(125)
expect(showAssistantReplyPreview).toHaveBeenCalledTimes(2)
expect(showAssistantReplyPreview).toHaveBeenLastCalledWith('最终预览')
wrapper.unmount()
})
})
@@ -0,0 +1,46 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import AgentMarkdownContent from '@/components/agent/AgentMarkdownContent.vue'
describe('AgentMarkdownContent', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('throttles streaming Markdown and renders the final content immediately', async () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '**开始**', streaming: true },
})
expect(wrapper.html()).toContain('<strong>开始</strong>')
await wrapper.setProps({ content: '**开始继续**' })
expect(wrapper.html()).not.toContain('<strong>开始继续</strong>')
await vi.advanceTimersByTimeAsync(96)
expect(wrapper.html()).toContain('<strong>开始继续</strong>')
await wrapper.setProps({ content: '**最终结果**', streaming: false })
expect(wrapper.html()).toContain('<strong>最终结果</strong>')
})
it('escapes raw HTML from Agent output', () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '<img src=x onerror="alert(1)">' },
})
expect(wrapper.html()).not.toContain('<img')
expect(wrapper.text()).toContain('<img src=x onerror=')
})
it('does not render a bubble for whitespace-only Markdown', () => {
const wrapper = mount(AgentMarkdownContent, {
props: { content: '\n\n' },
})
expect(wrapper.find('.agent-assistant-message__bubble').exists()).toBe(false)
})
})
+11 -29
View File
@@ -11,9 +11,9 @@ import { getDisplayImageUrl } from '@/utils/imageUtils'
type RecognitionStatusFilter = 'all' | 'recognized' | 'unrecognized'
type InfiniteScrollStatus = 'ok' | 'empty' | 'loading' | 'error'
type RecognitionCacheSource = 'tmdb' | 'douban'
const MOBILE_CACHE_PAGE_SIZE = 20
const RECOGNITION_CACHE_ENDPOINT = 'tmdb/cache'
const { t } = useI18n()
const display = useDisplay()
@@ -22,18 +22,8 @@ const $toast = useToast()
const globalSettingsStore = useGlobalSettingsStore()
const isMobile = computed(() => display.smAndDown.value)
const recognitionSource = computed<RecognitionCacheSource>(() =>
globalSettingsStore.globalSettings.RECOGNIZE_SOURCE === 'douban' ? 'douban' : 'tmdb',
)
const recognitionSourceName = computed(() =>
recognitionSource.value === 'douban'
? t('setting.cache.recognitionSource.douban')
: t('setting.cache.recognitionSource.themoviedb'),
)
const recognitionIdLabel = computed(() =>
recognitionSource.value === 'douban' ? t('setting.cache.doubanId') : t('setting.cache.tmdbId'),
)
const recognitionCacheEndpoint = computed(() => `${recognitionSource.value}/cache`)
const recognitionSourceName = computed(() => t('setting.cache.recognitionSource.themoviedb'))
const recognitionIdLabel = computed(() => t('setting.cache.tmdbId'))
const recognitionFilterPlaceholder = computed(() =>
t('setting.cache.filterRecognitionCache', { source: recognitionSourceName.value }),
)
@@ -106,12 +96,12 @@ function loadMoreMobileCache({ done }: { done: (status: InfiniteScrollStatus) =>
done(mobileHasMore.value ? 'ok' : 'empty')
}
/** 加载当前识别数据源对应的缓存列表。 */
/** 加载 TMDB 主识别缓存列表。 */
async function loadCacheData(showSuccess = false) {
const requestId = ++cacheLoadRequestId
try {
loading.value = true
const response = (await api.get(recognitionCacheEndpoint.value)) as unknown as ApiResponse<RecognitionCacheData>
const response = (await api.get(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse<RecognitionCacheData>
if (requestId !== cacheLoadRequestId) return
const responseData = response.data ?? {
count: 0,
@@ -139,7 +129,7 @@ async function loadCacheData(showSuccess = false) {
}
}
/** 清空当前识别数据源的全部识别缓存。 */
/** 清空全部 TMDB 主识别缓存。 */
async function clearAllCache() {
const confirmed = await createConfirm({
type: 'warn',
@@ -150,7 +140,7 @@ async function clearAllCache() {
try {
loading.value = true
const response = (await api.delete(recognitionCacheEndpoint.value)) as unknown as ApiResponse
const response = (await api.delete(RECOGNITION_CACHE_ENDPOINT)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
$toast.success(response.message || t('setting.cache.clearSuccess'))
await loadCacheData()
@@ -163,10 +153,10 @@ async function clearAllCache() {
}
}
/** 请求当前识别数据源接口删除指定识别缓存。 */
/** 请求 TMDB 接口删除指定识别缓存。 */
async function deleteCacheItem(key: string) {
const response = (await api.delete(
`${recognitionCacheEndpoint.value}/${encodeURIComponent(key)}`,
`${RECOGNITION_CACHE_ENDPOINT}/${encodeURIComponent(key)}`,
)) as unknown as ApiResponse
if (!response.success) throw new Error(response.message)
}
@@ -217,10 +207,9 @@ function getPosterUrl(item: RecognitionCacheItem): string {
return getDisplayImageUrl(sourceUrl, globalSettingsStore.globalSettings.GLOBAL_IMAGE_CACHE)
}
/** 获取当前识别数据源对应的媒体 ID。 */
/** 获取识别缓存对应的 TMDB ID。 */
function getRecognitionId(item: RecognitionCacheItem): string {
const recognitionId = recognitionSource.value === 'douban' ? item.douban_id : item.tmdb_id
return recognitionId ? String(recognitionId) : ''
return item.tmdb_id ? String(item.tmdb_id) : ''
}
/** 判断识别缓存条目是否包含有效媒体 ID。 */
@@ -260,13 +249,6 @@ onMounted(() => {
watch([searchFilter, statusFilter], () => {
resetMobilePagination()
})
watch(recognitionSource, () => {
searchFilter.value = ''
statusFilter.value = 'all'
selectedItems.value = []
void loadCacheData()
})
</script>
<template>
@@ -22,13 +22,13 @@ vi.mock('vue-toastification', () => ({
}),
}))
async function renderRecognitionCachePanel() {
async function renderRecognitionCachePanel(recognitionSource = 'themoviedb') {
return renderWithProviders(RecognitionCachePanel, {
initialState: {
globalSettings: {
data: {
GLOBAL_IMAGE_CACHE: false,
RECOGNIZE_SOURCE: 'themoviedb',
RECOGNIZE_SOURCE: recognitionSource,
TMDB_IMAGE_DOMAIN: 'image.tmdb.org',
},
},
@@ -79,4 +79,21 @@ describe('RecognitionCachePanel shared recognition statistics', () => {
expect(screen.queryByText('共享识别')).not.toBeInTheDocument()
})
it('loads TMDB cache even when Douban is selected as the recognition source', async () => {
mocks.apiGet.mockResolvedValue({
data: {
count: 0,
recognized: 0,
unrecognized: 0,
shared_recognized: 0,
shared_recognize_enabled: false,
data: [],
},
})
await renderRecognitionCachePanel('douban')
await waitFor(() => expect(mocks.apiGet).toHaveBeenCalledWith('tmdb/cache'))
})
})
+16 -27
View File
@@ -174,24 +174,6 @@ async function deleteDownload() {
</div>
<VCardText class="downloading-card__body">
<div class="downloading-card__chips">
<VChip
v-if="mediaTypeText"
:prepend-icon="mediaTypeIcon"
color="primary"
size="x-small"
variant="tonal"
>
{{ mediaTypeText }}
</VChip>
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
{{ sourceSiteText }}
</VChip>
<VChip v-else prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
{{ sizeText }}
</VChip>
</div>
<div class="downloading-card__heading">
<div class="downloading-card__title" :title="mediaTitle">
<span>{{ mediaTitle }}</span>
@@ -202,6 +184,18 @@ async function deleteDownload() {
</div>
</div>
<div class="downloading-card__chips">
<VChip v-if="mediaTypeText" :prepend-icon="mediaTypeIcon" size="x-small" variant="tonal">
{{ mediaTypeText }}
</VChip>
<VChip prepend-icon="mdi-harddisk" size="x-small" variant="tonal">
{{ sizeText }}
</VChip>
<VChip v-if="sourceSiteText" prepend-icon="mdi-web" size="x-small" variant="tonal">
{{ sourceSiteText }}
</VChip>
</div>
<div v-if="progressValue > 0" class="downloading-card__progress">
<div class="downloading-card__progress-label">
<span>
@@ -323,12 +317,15 @@ async function deleteDownload() {
.downloading-card__chips {
display: flex;
min-inline-size: 0;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.downloading-card__chips :deep(.v-chip) {
max-inline-size: calc(50% - 0.2rem);
flex: 0 1 auto;
min-inline-size: 0;
max-inline-size: 100%;
}
.downloading-card__chips :deep(.v-chip__content) {
@@ -499,14 +496,6 @@ async function deleteDownload() {
padding-inline: 0.65rem !important;
}
.downloading-card__chips :deep(.v-chip) {
max-inline-size: 100%;
}
.downloading-card__chips :deep(.v-chip:first-child:last-child) {
display: inline-flex;
}
.downloading-card__actions :deep(.v-btn) {
block-size: 2.25rem;
inline-size: 2.25rem;
@@ -140,10 +140,16 @@ describe('DownloadingCard display and pause state', () => {
expect(container.querySelectorAll('.downloading-card__chips .v-chip')).toHaveLength(1)
})
it('prefers explicit site names and safely reduces tracker URLs to hostnames', async () => {
it('keeps torrent size visible while resolving explicit site names and tracker hostnames', async () => {
const { container, rerender } = await renderCard(downloading({ site_name: ' M-Team ' }))
expect(container.querySelector('.downloading-card__chips')).toHaveTextContent('M-Team')
const heading = container.querySelector('.downloading-card__heading')!
const chipContainer = container.querySelector('.downloading-card__chips')!
const chips = [...chipContainer.querySelectorAll('.v-chip')]
expect(heading.nextElementSibling).toBe(chipContainer)
expect(chips.map(chip => chip.textContent?.trim())).toEqual(['电视剧', '1.00 KB', 'M-Team'])
expect(chips.every(chip => !chip.classList.contains('text-primary'))).toBe(true)
await rerender({
downloaderName: 'qb-main',
@@ -299,7 +299,6 @@ onMounted(() => {
<VCol cols="12">
<VTextField
v-model="mediaId"
class="app-responsive-input--keep-append-action"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
@@ -241,7 +241,6 @@ onMounted(() => {
<VCol cols="12">
<VTextField
v-model="mediaId"
class="app-responsive-input--keep-append-action"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
:rules="[numberValidator]"
+1 -1
View File
@@ -151,7 +151,7 @@ async function registerPassKey() {
} else if (error.message?.includes('start failed')) {
$toast.error(t('login.passkeyLoginStartFailed'))
} else if (error.response) {
$toast.error(error.response.data?.detail || t('profile.passkeyRegisterFailed'))
$toast.error(error.response.data?.message || error.response.data?.detail || t('profile.passkeyRegisterFailed'))
} else {
$toast.error(error.message || t('profile.passkeyRegisterFailed'))
}
@@ -24,7 +24,7 @@ const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const syncingWiki = ref(false)
const syncingSources = ref(false)
const emit = defineEmits(['save', 'close'])
@@ -139,10 +139,10 @@ async function saveHandle() {
}
}
/** 从 Wiki 同步公开插件仓库清单并写入配置。 */
async function syncWikiRepos() {
/** 同步公开插件清单并写入配置。 */
async function syncPluginSources() {
try {
syncingWiki.value = true
syncingSources.value = true
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET/sync-wiki', {})
if (result.success) {
@@ -164,7 +164,7 @@ async function syncWikiRepos() {
console.log(error)
$toast.error(t('dialog.pluginMarketSetting.syncFailed', { message: error instanceof Error ? error.message : '' }))
} finally {
syncingWiki.value = false
syncingSources.value = false
}
}
@@ -483,11 +483,11 @@ onMounted(() => {
color="success"
variant="tonal"
prepend-icon="mdi-cloud-sync-outline"
:loading="syncingWiki"
:disabled="syncingWiki"
@click="syncWikiRepos"
:loading="syncingSources"
:disabled="syncingSources"
@click="syncPluginSources"
>
{{ t('dialog.pluginMarketSetting.syncWiki') }}
{{ t('dialog.pluginMarketSetting.syncSources') }}
</VBtn>
<VSpacer />
<VBtn
@@ -506,6 +506,8 @@ onMounted(() => {
</template>
<style scoped lang="scss">
/* stylelint-disable selector-pseudo-class-no-unknown */
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
@@ -570,9 +572,12 @@ onMounted(() => {
.plugin-market-mode-switch {
display: inline-flex;
padding: 0.125rem;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 0.375rem;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-control-radius);
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
gap: 0.125rem;
}
@@ -582,7 +587,7 @@ onMounted(() => {
justify-content: center;
padding: 0;
border: 0;
border-radius: 0.375rem;
border-radius: var(--app-control-radius);
background: transparent;
block-size: 2.25rem;
color: rgba(var(--v-theme-on-surface), 0.68);
@@ -594,7 +599,7 @@ onMounted(() => {
color 0.16s ease;
&:hover {
background: rgba(var(--v-theme-primary), 0.07);
background: var(--app-grouped-list-hover-background);
color: rgb(var(--v-theme-on-surface));
}
@@ -604,7 +609,7 @@ onMounted(() => {
}
&.is-active {
background: rgba(var(--v-theme-primary), 0.12);
background: var(--app-grouped-list-active-background);
color: rgb(var(--v-theme-primary));
}
}
@@ -624,13 +629,23 @@ onMounted(() => {
.plugin-market-list-wrap {
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-grouped-list-radius);
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
min-block-size: 0;
overflow-y: auto;
}
.plugin-market-repo-list {
background: transparent;
border: 0;
border-radius: inherit !important;
-webkit-backdrop-filter: none !important;
backdrop-filter: none !important;
background: transparent !important;
box-shadow: none !important;
}
.plugin-market-repo-item {
@@ -681,7 +696,12 @@ onMounted(() => {
display: flex;
overflow: hidden;
flex: 1;
background: rgba(var(--v-theme-surface), 0.72);
border: var(--app-grouped-list-border);
border-radius: var(--app-grouped-list-radius);
-webkit-backdrop-filter: var(--app-grouped-list-backdrop-filter);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
min-block-size: 0;
transition:
border-color 0.2s ease,
@@ -1514,7 +1514,6 @@ onUnmounted(() => {
<VCol cols="12" md="4">
<VTextField
v-model="transferForm.media_id"
class="app-responsive-input--keep-append-action"
:disabled="transferForm.type_name === ''"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
-1
View File
@@ -147,7 +147,6 @@ watch(mediaSource, () => {
<VCol cols="12" md="4">
<VTextField
v-model="mediaId"
class="app-responsive-input--keep-append-action"
:disabled="mediaType === ''"
:label="mediaIdLabel"
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
@@ -69,10 +69,9 @@ async function updateSiteCookie() {
}
} catch (error: any) {
console.error(error)
const detail = error?.response?.data?.detail
const message =
error?.response?.data?.message ||
(typeof detail === 'string' ? detail : error?.message) ||
(typeof error?.response?.data?.detail === 'string' ? error.response.data.detail : error?.message) ||
t('dialog.siteCookieUpdate.requestFailed')
$toast.error(t('dialog.siteCookieUpdate.failed', { site: cardProps.site?.name, message }))
} finally {
@@ -0,0 +1,38 @@
import { readFileSync } from 'node:fs'
import { cwd } from 'node:process'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const dialogSource = readFileSync(resolve(cwd(), 'src/components/dialog/PluginMarketSettingDialog.vue'), 'utf8')
function getStyleRule(selector: string) {
const ruleStart = dialogSource.indexOf(`${selector} {`)
const ruleEnd = dialogSource.indexOf('\n}', ruleStart)
expect(ruleStart).toBeGreaterThanOrEqual(0)
expect(ruleEnd).toBeGreaterThan(ruleStart)
return dialogSource.slice(ruleStart, ruleEnd)
}
describe('PluginMarketSettingDialog theme surfaces', () => {
it('uses shared theme tokens for the view switch and editor containers', () => {
const modeSwitchRule = getStyleRule('.plugin-market-mode-switch')
const listWrapRule = getStyleRule('.plugin-market-list-wrap')
const textareaRule = getStyleRule('.plugin-market-textarea-field')
expect(modeSwitchRule).toContain('border: var(--app-grouped-list-border)')
expect(modeSwitchRule).toContain('backdrop-filter: var(--app-grouped-list-backdrop-filter)')
expect(modeSwitchRule).toContain('background: var(--app-grouped-list-background)')
expect(dialogSource).toContain('background: var(--app-grouped-list-hover-background)')
expect(dialogSource).toContain('background: var(--app-grouped-list-active-background)')
expect(listWrapRule).toContain('border-radius: var(--app-grouped-list-radius)')
expect(listWrapRule).toContain('background: var(--app-grouped-list-background)')
expect(textareaRule).toContain('background: var(--app-grouped-list-background)')
})
it('uses plugin-source wording instead of exposing the Wiki implementation detail', () => {
expect(dialogSource).toContain("t('dialog.pluginMarketSetting.syncSources')")
expect(dialogSource).not.toContain("t('dialog.pluginMarketSetting.syncWiki')")
})
})
@@ -148,6 +148,15 @@ describe('SiteUserDataDialog projections', () => {
expect((history.options.xaxis as { categories: string[] }).categories).toEqual(['2026-07-17', '2026-07-19'])
expect((history.options.theme as { mode: string }).mode).toBe('light')
expect((history.options.chart as { background: string; foreColor: string }).background).toBeTruthy()
expect((history.options.tooltip as { x: { formatter: (value: string) => string } }).x.formatter('2026-07-19')).toBe(
new Date('2026-07-19').toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
}),
)
expect((history.options.tooltip as { y: { formatter: (value: number) => string } }).y.formatter(1234)).toBe(
`${(1234).toLocaleString()} GB`,
)
expect(
(history.options.xaxis as { labels: { formatter: (value: string) => string } }).labels.formatter('2026-07-19'),
).toBe(
@@ -174,6 +183,9 @@ describe('SiteUserDataDialog projections', () => {
expect((seeding.options.tooltip as { x: { formatter: (value: number) => string } }).x.formatter(1234)).toBe(
`数量:${(1234).toLocaleString()}`,
)
expect((seeding.options.tooltip as { y: { formatter: (value: number) => string } }).y.formatter(2048)).toBe(
`${(2048).toLocaleString()} GB`,
)
expect((seeding.options.xaxis as { labels: { formatter: (value: number) => string } }).labels.formatter(1.6)).toBe(
'2',
)
+8
View File
@@ -0,0 +1,8 @@
// Agent 助手需要覆盖应用内所有普通浮层,因此从 CSS 32 位层级上限向下预留内部顺序。
const MAX_CSS_Z_INDEX = 2_147_483_647
export const AGENT_ASSISTANT_LAYER_Z_INDEX = {
entry: MAX_CSS_Z_INDEX - 2,
panel: MAX_CSS_Z_INDEX - 1,
overlay: MAX_CSS_Z_INDEX,
} as const
+7 -3
View File
@@ -4,7 +4,12 @@ import { useDisplay } from 'vuetify'
import { NavMenu } from '@/@layouts/types'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores'
import { buildUserPermissionContext, filterItemsByPermission, filterMenusByPermission, hasItemPermission } from '@/utils/permission'
import {
buildUserPermissionContext,
filterItemsByPermission,
filterMenusByPermission,
hasItemPermission,
} from '@/utils/permission'
import { useLaunchLoading } from '@/composables/useLaunchLoading'
import { usePWA } from '@/composables/usePWA'
import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
@@ -327,8 +332,7 @@ function handleDynamicMenuItemClick(item: DynamicButtonMenuItem) {
pointer-events: none;
}
// body Agent .v-application
// z-index
//
html[data-theme-customizer-open='true'],
html[data-agent-assistant-open='true'] {
.footer-nav-container {
+6 -6
View File
@@ -782,7 +782,7 @@ export default {
emptyTitle: 'What should we handle today?',
emptySubtitle: 'Ask about sites, subscriptions, downloads, or organization tasks.',
placeholder: 'Ask MoviePilot, Type / for commands',
processingPlaceholder: 'MoviePilot is working, please wait...',
processingPlaceholder: 'Processing...',
commandLoading: 'Loading commands...',
commandLoadFailed: 'Failed to load commands',
stop: 'Stop generating',
@@ -2033,8 +2033,8 @@ export default {
tmdbLocale: 'TMDB Metadata Language',
tmdbLocalePlaceholder: 'en',
tmdbLocaleHint: 'Customize TheMovieDb metadata language',
metaCacheExpire: 'Media Metadata Cache Expiration Time',
metaCacheExpireHint: 'Recognition metadata local cache time, use built-in default value when set to 0',
metaCacheExpire: 'Per-item Media Metadata Cache TTL',
metaCacheExpireHint: 'Independent TTL for each recognition metadata item; use the built-in default when set to 0',
metaCacheExpireRequired: 'Please enter metadata cache time',
metaCacheExpireMin: 'Metadata cache time must be greater than or equal to 0',
scrapFollowTmdb: 'Follow TMDB Recognition',
@@ -3061,9 +3061,9 @@ export default {
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
invalidTextIgnored: '{count} invalid URLs ignored',
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
syncWiki: 'Sync Wiki',
syncSuccess: 'Plugin repositories synced from Wiki. {added} added, {total} total.',
syncFailed: 'Failed to sync Wiki: {message}!',
syncSources: 'Sync Plugin Sources',
syncSuccess: 'Plugin sources synced. {added} added, {total} total.',
syncFailed: 'Failed to sync plugin sources: {message}!',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',
+6 -6
View File
@@ -771,7 +771,7 @@ export default {
emptyTitle: '今天想处理什么?',
emptySubtitle: '站点、订阅、下载、整理任务,都可以直接问我。',
placeholder: '询问 MoviePilot,输入 / 使用命令',
processingPlaceholder: '智能体正在处理,请稍候...',
processingPlaceholder: '处理中...',
commandLoading: '正在加载命令...',
commandLoadFailed: '命令列表加载失败',
stop: '停止生成',
@@ -2008,8 +2008,8 @@ export default {
tmdbLocale: 'TMDB 元数据语言',
tmdbLocalePlaceholder: 'zh',
tmdbLocaleHint: '自定义 TheMovieDb 元数据语言',
metaCacheExpire: '媒体元数据缓存过期时间',
metaCacheExpireHint: '识别元数据本地缓存时间,为 0 时使用内置默认值',
metaCacheExpire: '单条媒体元数据缓存有效期',
metaCacheExpireHint: '每条识别元数据的独立有效期,为 0 时使用内置默认值',
metaCacheExpireRequired: '请输入元数据缓存时间',
metaCacheExpireMin: '元数据缓存时间必须大于等于0',
scrapFollowTmdb: '跟随TMDB识别整理',
@@ -3006,9 +3006,9 @@ export default {
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已从 Wiki 同步插件仓库,新增 {added} 个,共 {total} 个',
syncFailed: '同步 Wiki 失败:{message}',
syncSources: '同步插件源',
syncSuccess: '已同步插件,新增 {added} 个,共 {total} 个',
syncFailed: '同步插件源失败:{message}',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',
+6 -6
View File
@@ -771,7 +771,7 @@ export default {
emptyTitle: '今天想處理什麼?',
emptySubtitle: '站點、訂閱、下載、整理任務,都可以直接問我。',
placeholder: '詢問 MoviePilot,輸入 / 使用命令',
processingPlaceholder: '智能體正在處理,請稍候...',
processingPlaceholder: '處理中...',
commandLoading: '正在載入命令...',
commandLoadFailed: '命令列表載入失敗',
stop: '停止生成',
@@ -2007,8 +2007,8 @@ export default {
tmdbLocale: 'TMDB 元數據語言',
tmdbLocalePlaceholder: 'zh',
tmdbLocaleHint: '自定義 TheMovieDb 元數據語言',
metaCacheExpire: '媒體元數據緩存過期時間',
metaCacheExpireHint: '識別元數據本地緩存時間,為 0 時使用內置默認值',
metaCacheExpire: '單筆媒體元數據緩存有效期',
metaCacheExpireHint: '每筆識別元數據的獨立有效期,為 0 時使用內置默認值',
metaCacheExpireRequired: '請輸入元數據緩存時間',
metaCacheExpireMin: '元數據緩存時間必須大於等於0',
scrapFollowTmdb: '跟隨TMDB識別整理',
@@ -3005,9 +3005,9 @@ export default {
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
syncWiki: '同步 Wiki',
syncSuccess: '已從 Wiki 同步插件倉庫,新增 {added} 個,共 {total} 個',
syncFailed: '同步 Wiki 失敗:{message}',
syncSources: '同步插件源',
syncSuccess: '已同步插件,新增 {added} 個,共 {total} 個',
syncFailed: '同步插件源失敗:{message}',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',
+10 -2
View File
@@ -131,6 +131,8 @@ interface PluginAuthPayload {
}
interface ApiErrorPayload {
message?: unknown
message_i18n?: unknown
detail?: unknown
mfa_methods?: unknown
}
@@ -278,9 +280,9 @@ async function exchangePluginAuthTicket(ticket: string) {
} catch (error: unknown) {
console.error('插件认证票据兑换失败:', error)
const apiError = asApiError(error)
const detail = apiError.response?.data?.detail
const message = apiError.response?.data?.message || apiError.response?.data?.detail
pluginAuthError.value =
(typeof detail === 'string' ? detail : undefined) || getErrorMessage(error) || t('login.authFailure')
(typeof message === 'string' ? message : undefined) || getErrorMessage(error) || t('login.authFailure')
} finally {
pluginAuthLoading.value = false
}
@@ -619,6 +621,12 @@ function setLoginError(error: unknown) {
return
}
const message = apiError.response.data?.message
if (typeof message === 'string' && message) {
errorMessage.value = message
return
}
switch (apiError.response.status) {
case 401:
errorMessage.value = t('login.authFailure')
@@ -86,6 +86,17 @@ describe('glass overlay material styles', () => {
)
})
it('uses the shared theme foreground token for confirm dialog actions', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const dialog = readFileSync(resolve(cwd(), 'src/@core/components/ConfirmDialog.vue'), 'utf8')
expect(dialog).toContain('app-confirm-dialog-actions')
expect(styles).toContain('--app-confirm-dialog-action-color: rgb(var(--v-theme-on-primary))')
expect(styles).toMatch(
/\.app-confirm-dialog-actions \.v-btn\s*\{[\s\S]*?color:\s*var\(--app-confirm-dialog-action-color\)\s*!important;/,
)
})
it('keeps Chromium frosted fixed shells on the stable wallpaper backplate', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const backplate = readFileSync(resolve(cwd(), 'src/components/theme/GlassFixedShellBackplate.vue'), 'utf8')
@@ -139,6 +150,23 @@ describe('glass overlay material styles', () => {
)
})
it('reuses the popup menu material for compact FAB buttons', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
const ruleStart = styles.indexOf('.compact-fab .v-btn {')
const ruleEnd = styles.indexOf('\n }', ruleStart)
const rule = styles.slice(ruleStart, ruleEnd)
expect(ruleStart).toBeGreaterThanOrEqual(0)
expect(rule).toContain('border: 1px solid var(--glass-border-raised) !important')
expect(rule).toContain('backdrop-filter: var(--glass-overlay-backdrop-filter) !important')
expect(rule).toContain('background-color: var(--glass-overlay-surface) !important')
expect(rule).toContain('background-image: var(--glass-sheen) !important')
expect(rule).toContain('box-shadow: var(--glass-shadow-raised) !important')
expect(styles).toMatch(
/\.compact-fab \.v-btn:hover\s*\{\s*background-color:\s*var\(--glass-overlay-surface\)\s*!important;/,
)
})
it('uses the shared hover-card contract instead of a Dashboard-specific shadow rule', () => {
const styles = readFileSync(resolve(cwd(), 'src/styles/themes/glass.scss'), 'utf8')
@@ -0,0 +1,19 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { cwd } from 'node:process'
import { describe, expect, it } from 'vitest'
describe('mobile responsive input actions', () => {
it('keeps functional trailing controls without restoring decorative icons', () => {
const commonStyles = readFileSync(resolve(cwd(), 'src/styles/common.scss'), 'utf8')
expect(commonStyles).toMatch(
/\.app-responsive-input__native \.v-field__clearable,\s*\.app-responsive-input__native\s+\.v-field__append-inner:has\([\s\S]*?\)\s*\{\s*display: inline-flex;/,
)
expect(commonStyles).toContain("[role='button']:not(")
expect(commonStyles).toContain('.v-select__menu-icon')
expect(commonStyles).toContain('.v-autocomplete__menu-icon')
expect(commonStyles).toContain('.v-combobox__menu-icon')
expect(commonStyles).not.toContain('app-responsive-input--keep-append-action')
})
})
+8 -4
View File
@@ -543,16 +543,20 @@ html[data-theme-radius='extra'] {
min-inline-size: 0;
}
// 移动录入行只保留值本身统一移除字段内部的装饰与操作图标
// 移动录入行隐藏字段内部的装饰图标避免挤压右栏中的实际内容
.app-responsive-input__native .v-field__prepend-inner,
.app-responsive-input__native .v-field__append-inner,
.app-responsive-input__native .v-field__clearable {
display: none;
}
// 少数字段的内嵌图标承担了唯一的交互入口如按名称搜索媒体编号
// 不是纯装饰移动端也需要保留可点击
.app-responsive-input--keep-append-action .app-responsive-input__native .v-field__append-inner {
// 清除控件与具备按钮语义的末端操作仍需可用下拉箭头仅重复整字段操作不单独展示
.app-responsive-input__native .v-field__clearable,
.app-responsive-input__native
.v-field__append-inner:has(
button,
[role='button']:not(.v-select__menu-icon, .v-autocomplete__menu-icon, .v-combobox__menu-icon)
) {
display: inline-flex;
}
+21 -1
View File
@@ -106,6 +106,7 @@ html[data-theme='glass'] {
--app-card-light-border-opacity: 0.15;
--app-card-light-border: 1px solid var(--glass-border);
--app-overlay-border: 1px solid var(--glass-border);
--app-confirm-dialog-action-color: rgb(var(--v-theme-on-primary));
--app-grouped-list-background: var(--glass-surface);
--app-grouped-list-backdrop-filter: var(--glass-surface-backdrop-filter);
--app-grouped-list-border: 1px solid var(--glass-border);
@@ -603,7 +604,6 @@ html[data-theme='glass'] {
}
.nav-button,
.compact-fab .v-btn,
.global-action-buttons .v-btn {
border: 1px solid var(--glass-border) !important;
-webkit-backdrop-filter: var(--glass-raised-backdrop-filter);
@@ -613,6 +613,16 @@ html[data-theme='glass'] {
box-shadow: var(--glass-control-shadow) !important;
}
// 聚合 FAB 与弹出菜单使用同一磨砂材质展开后保持稳定底色和背景采样
.compact-fab .v-btn {
border: 1px solid var(--glass-border-raised) !important;
-webkit-backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
backdrop-filter: var(--glass-overlay-backdrop-filter) !important;
background-color: var(--glass-overlay-surface) !important;
background-image: var(--glass-sheen) !important;
box-shadow: var(--glass-shadow-raised) !important;
}
.v-overlay__scrim {
-webkit-backdrop-filter: none;
backdrop-filter: none;
@@ -799,6 +809,11 @@ html[data-theme='glass'] {
box-shadow var(--glass-motion);
}
// 语义色按钮在半透明玻璃表面上统一使用主题的高对比前景色避免继承深色 on-color
.app-confirm-dialog-actions .v-btn {
color: var(--app-confirm-dialog-action-color) !important;
}
.v-btn:not(.v-btn--variant-text, .v-btn--variant-plain) {
border: 1px solid var(--glass-border);
-webkit-backdrop-filter: var(--glass-control-backdrop-filter);
@@ -1320,6 +1335,11 @@ html[data-theme='glass'] {
background-color: rgba(var(--v-theme-error), 42%) !important;
}
// FAB 悬浮时保持弹出菜单材质仅通过描边和阴影反馈交互避免背景进一步透明
.compact-fab .v-btn:hover {
background-color: var(--glass-overlay-surface) !important;
}
.v-card--link:hover {
border-color: var(--glass-border-hover) !important;
box-shadow: var(--glass-shadow-hover) !important;
+21
View File
@@ -0,0 +1,21 @@
import MarkdownIt from 'markdown-it'
import mdLinkAttributes from 'markdown-it-link-attributes'
const agentMarkdown = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
typographer: true,
})
agentMarkdown.use(mdLinkAttributes, {
attrs: {
target: '_blank',
rel: 'noopener noreferrer',
},
})
// Agent 内容来自模型和工具输出,禁用原始 HTML 后统一转换为可展示 Markdown。
export function renderAgentMarkdown(content: string) {
return content ? agentMarkdown.render(content) : ''
}
+51 -3
View File
@@ -4,6 +4,7 @@ import api from '@/api'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { configureAceEditorPadding } from '@/utils/aceEditor'
import type { Ace } from 'ace-builds'
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
@@ -52,7 +53,27 @@ const saving = ref(false)
const showLineNumbers = ref(localStorage.getItem(WORDS_LINE_NUMBERS_STORAGE_KEY) === 'true')
const showSyntaxHighlighting = ref(localStorage.getItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY) === 'true')
const textEditorLanguage = computed(() => (showSyntaxHighlighting.value ? 'word_list_syntax' : 'word_list'))
interface WordListModeConfig {
path: 'ace/mode/word_list'
syntax: boolean
}
const aceEditor = shallowRef<Ace.Editor | null>(null)
function onAceInit(editor: Ace.Editor) {
aceEditor.value = editor
configureAceEditorPadding(editor)
applyWordListSyntax()
}
function applyWordListSyntax() {
if (!aceEditor.value) return
const mode: WordListModeConfig = {
path: 'ace/mode/word_list',
syntax: showSyntaxHighlighting.value,
}
aceEditor.value.session.setMode(mode as unknown as Ace.SyntaxMode)
}
const textEditorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
const textEditorOptions = computed(() => ({
fontSize: 13.6,
@@ -73,6 +94,8 @@ watch(showSyntaxHighlighting, value => {
localStorage.setItem(WORDS_SYNTAX_HIGHLIGHTING_STORAGE_KEY, String(value))
})
watch(showSyntaxHighlighting, applyWordListSyntax)
const savedTextValues = reactive<Record<TextSectionKey, string>>({
identifiers: '',
releaseGroups: '',
@@ -528,14 +551,14 @@ onMounted(() => {
<VAceEditor
v-if="activeSection === 'identifiers'"
v-model:value="activeTextValue"
:lang="textEditorLanguage"
lang="word_list"
:theme="textEditorTheme"
:options="textEditorOptions"
:placeholder="activeTextPlaceholder"
:print-margin="false"
wrap
class="words-text-editor"
@init="configureAceEditorPadding"
@init="onAceInit"
/>
<VTextarea
v-else
@@ -924,6 +947,9 @@ onMounted(() => {
--words-token-block: #af00db;
--words-token-replaced: #001080;
--words-token-replacement: #a31515;
--words-token-parameter-syntax: #795e26;
--words-token-parameter-key: #0451a5;
--words-token-parameter-value: #098658;
--words-token-front: #267f99;
--words-token-back: #795e26;
--words-token-offset: #098658;
@@ -943,6 +969,9 @@ onMounted(() => {
--words-token-block: #c586c0;
--words-token-replaced: #9cdcfe;
--words-token-replacement: #ce9178;
--words-token-parameter-syntax: #dcdcaa;
--words-token-parameter-key: #9cdcfe;
--words-token-parameter-value: #b5cea8;
--words-token-front: #4ec9b0;
--words-token-back: #dcdcaa;
--words-token-offset: #b5cea8;
@@ -982,6 +1011,25 @@ onMounted(() => {
color: var(--words-token-replacement);
}
.words-text-editor :deep(.ace_word_list_parameter_syntax) {
color: var(--words-token-parameter-syntax);
}
.words-text-editor :deep(.ace_word_list_parameter_key) {
color: var(--words-token-parameter-key);
}
.words-text-editor :deep(.ace_word_list_parameter_value) {
color: var(--words-token-parameter-value);
}
.words-text-editor :deep(.ace_invalid.ace_word-list) {
color: #f44336 !important;
background-color: transparent !important;
text-decoration: underline wavy #f44336 !important;
text-underline-offset: 0.12em;
}
.words-text-editor :deep(.ace_word_list_front) {
color: var(--words-token-front);
}
+19 -4
View File
@@ -10,6 +10,9 @@ const mocks = vi.hoisted(() => ({
apiPost: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
aceSetMode: vi.fn(),
aceSetPadding: vi.fn(),
aceSetScrollMargin: vi.fn(),
}))
vi.mock('@/api', () => ({
@@ -33,6 +36,13 @@ const AceEditorStub = defineComponent({
options: { type: Object, default: () => ({}) },
value: { type: String, default: '' },
},
emits: ['init', 'update:value'],
mounted() {
this.$emit('init', {
session: { setMode: mocks.aceSetMode },
renderer: { setPadding: mocks.aceSetPadding, setScrollMargin: mocks.aceSetScrollMargin },
})
},
template: `
<div
data-testid="words-ace-editor"
@@ -61,6 +71,9 @@ describe('WordsView editor preferences', () => {
return Promise.resolve({ data: { value: ['alpha', 'beta'] } })
})
mocks.apiPost.mockResolvedValue({ success: true })
mocks.aceSetMode.mockClear()
mocks.aceSetPadding.mockClear()
mocks.aceSetScrollMargin.mockClear()
})
it('keeps line numbers disabled when no preference is stored', async () => {
@@ -74,6 +87,7 @@ describe('WordsView editor preferences', () => {
expect(editor).toHaveAttribute('data-show-line-numbers', 'false')
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).not.toBeChecked()
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: false })
expect(localStorage.getItem('MP_WORDS_SHOW_LINE_NUMBERS')).toBeNull()
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBeNull()
})
@@ -110,7 +124,8 @@ describe('WordsView editor preferences', () => {
const editor = await screen.findByTestId('words-ace-editor')
expect(screen.getByRole('checkbox', { name: '语法高亮' })).toBeChecked()
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
expect(mocks.aceSetMode).toHaveBeenCalledWith({ path: 'ace/mode/word_list', syntax: true })
expect(editor).toHaveAttribute('data-lang', 'word_list')
})
it('updates Ace options and persists the preference without changing content', async () => {
@@ -139,7 +154,7 @@ describe('WordsView editor preferences', () => {
})
})
it('switches the Ace mode and persists syntax highlighting without changing content', async () => {
it('switches word list syntax highlighting and persists the preference without changing content', async () => {
const user = userEvent.setup()
await renderWordsView()
@@ -149,7 +164,7 @@ describe('WordsView editor preferences', () => {
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list_syntax')
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: true })
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('true')
})
expect(editor).toHaveAttribute('data-value', 'alpha\nbeta')
@@ -158,7 +173,7 @@ describe('WordsView editor preferences', () => {
await user.click(screen.getByRole('checkbox', { name: '语法高亮' }))
await waitFor(() => {
expect(editor).toHaveAttribute('data-lang', 'word_list')
expect(mocks.aceSetMode).toHaveBeenLastCalledWith({ path: 'ace/mode/word_list', syntax: false })
expect(localStorage.getItem('MP_WORDS_SYNTAX_HIGHLIGHTING')).toBe('false')
})
})