Compare commits

...

40 Commits
v2.10.2 ... v2

Author SHA1 Message Date
jxxghp
a9403c9c34 chore: bump version to 2.10.11 2026-05-07 08:23:20 +08:00
jxxghp
dc4914e3ca style: adjust downloader card API key field to span full width 2026-05-07 08:22:39 +08:00
jxxghp
f3dbc4afad feat: add qBittorrent API key setup support
Expose qBittorrent WebUI API Key fields in settings and setup so 5.2 users can connect without requiring username/password.

Refs jxxghp/MoviePilot#5724
2026-05-07 07:41:05 +08:00
jxxghp
e3e22aebd9 feat: replace log level chips with VSelect dropdown in LoggingView and adjust layout spacing 2026-05-06 13:04:35 +08:00
jxxghp
0ca2f20b24 refactor: update logging record layout to use block-level elements for better alignment and structure 2026-05-06 08:02:50 +08:00
jxxghp
14279c773d fix: update LoggingView layout to support responsive height for mobile devices 2026-05-05 12:37:30 +08:00
jxxghp
8372f63eb6 refactor: dynamic logging view height calculation and remove redundant LLM model refresh on settings save 2026-05-05 12:34:09 +08:00
jxxghp
b7b62d7922 feat: overhaul logging view with advanced filtering, grouped display, and real-time streaming controls 2026-05-05 11:53:21 +08:00
jxxghp
162cce1f50 feat: replace VSelect with VAutocomplete for LLM provider selection in settings 2026-05-04 20:04:14 +08:00
jxxghp
aa49c6ccbc refactor(llm): merge preset selection into base URL field
Use a single editable Base URL combobox for LLM providers so preset endpoints and manual input share one field, with preset types shown as subtitles.
2026-05-03 11:31:06 +08:00
jxxghp
a40e52079f 更新 package.json 2026-05-03 09:44:37 +08:00
jxxghp
c29e329548 feat(llm): add provider URL presets
Expose provider-specific preset endpoints in the setup and system settings flows so users can start from the correct base URL while still editing it manually.
2026-05-03 09:38:28 +08:00
mcgrady.sun
e2d26f6a25 fix(resource): 解决重新搜索按钮 review 问题
- 简化 refreshSearch:移除多余的 switchToOriginalResults 调用,
  直接置 showingAiResults=false,其余状态由 fetchData 内部重置
- 标题栏 v-if 去掉 !progressActive 条件,避免点击重新搜索时
  整个标题栏 unmount 导致按钮 :loading 不可见、页面跳动
2026-05-02 16:33:19 +08:00
mcgrady.sun
1752256868 feat(resource): 资源搜索结果页增加重新搜索按钮
- 在搜索结果页右侧操作区新增"重新搜索"按钮(mdi-refresh 图标)
- 点击后使用相同搜索参数重新触发请求;正在请求或加载中时按钮禁用
- 若当前展示的是 AI 推荐结果,先切回原始结果再重新搜索,避免状态不一致
- 同步补充 zh-CN / zh-TW / en-US 三份本地化文案
2026-05-02 16:33:19 +08:00
jxxghp
23d7f0dcc1 更新 package.json 2026-04-30 11:55:22 +08:00
jxxghp
288aeed178 chore: update LLM model hint examples in localization files 2026-04-30 11:29:17 +08:00
jxxghp
9a9a618136 refactor: extract LLM provider management logic into composable and add OAuth support for system settings 2026-04-30 09:49:05 +08:00
jxxghp
723eb319e1 feat: add batch AI reorganization support to Transfer History view 2026-04-29 20:37:52 +08:00
jxxghp
96684a8d13 feat: add configuration for AI voice input/output settings and models 2026-04-29 18:15:50 +08:00
jxxghp
fc9fe5e21e chore: bump version to 2.10.8 and adjust UI styling for scraping settings 2026-04-29 17:10:36 +08:00
jxxghp
24b763d808 refactor: comment out redundant refresh call in ShortcutBar message submission 2026-04-28 13:10:37 +08:00
jxxghp
f761cdff00 chore: bump version to 2.10.7 and improve message sending reliability with explicit refresh and type safety 2026-04-28 13:04:05 +08:00
jxxghp
b785769138 feat: add transparent theme support for AI agent settings card and adjust border opacity 2026-04-25 12:30:30 +08:00
jxxghp
6d1febd70a 更新 AccountSettingSystem.vue 2026-04-25 11:41:35 +08:00
jxxghp
bdbaf503ca feat: implement custom Jinja2 syntax highlighting for rename templates and notification templates using VAceEditor 2026-04-25 11:20:15 +08:00
jxxghp
f9e74cf436 style: group AI assistant settings in system page 2026-04-25 10:48:55 +08:00
jxxghp
e043669a10 更新 package.json 2026-04-24 20:25:09 +08:00
jxxghp
78d8fdba9d fix: restore setup agent grid layout 2026-04-24 20:20:36 +08:00
jxxghp
5c0f0386a6 chore: reorder llm thinking setting 2026-04-24 20:17:22 +08:00
jxxghp
30b39283b6 feat: add unified llm thinking level setting 2026-04-24 19:50:23 +08:00
jxxghp
de84c39d2f fix card action hit areas 2026-04-22 18:01:26 +08:00
jxxghp
65152e7e37 fix: 修复媒体服务器卡片关闭按钮被背景图遮挡的问题 2026-04-22 17:07:41 +08:00
jxxghp
ba343ce5fa style: adjust login page layout spacing and formatting for improved visual alignment 2026-04-21 22:20:04 +08:00
jxxghp
60495668a6 Simplify LLM settings connectivity test 2026-04-21 22:14:19 +08:00
jxxghp
f2ac624dbb fix: refine ai assistant settings feedback 2026-04-21 21:25:21 +08:00
jxxghp
6238849d3f refactor: optimize ai assistant settings actions 2026-04-21 21:11:29 +08:00
笨笨
82cb903c1f fix: localize llm test result labels 2026-04-21 20:41:39 +08:00
笨笨
5e5eb95b55 feat: add llm test button 2026-04-21 20:41:39 +08:00
jxxghp
74e6f8b03e bump version to 2.10.3 2026-04-21 08:55:50 +08:00
jxxghp
a2bf0d2b16 refine settings card layouts 2026-04-21 08:50:14 +08:00
29 changed files with 3863 additions and 547 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.10.2",
"version": "2.10.11",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -15,7 +15,7 @@ function onClick() {
<template>
<IconBtn
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3'"
:class="props.innerClass ? props.innerClass : 'absolute right-3 top-3 z-10'"
@click.stop="onClick"
>
<VIcon icon="mdi-close" />

View File

@@ -44,6 +44,488 @@ import snippertsIniUrl from 'ace-builds/src-noconflict/snippets/ini?url'
import 'ace-builds/src-noconflict/ext-language_tools'
const aceModule = ace as typeof ace & {
define?: (moduleName: string, deps: string[], payload: (...args: any[]) => void) => void
}
function registerJinja2Mode() {
aceModule.define?.(
'ace/mode/jinja2_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 Jinja2HighlightRules = function (this: any) {
const tags =
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
const filters =
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
const tests =
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
'keyword.control.jinja2': tags,
'keyword.operator.jinja2': operators,
'support.function.jinja2': [filters, functions, tests].join('|'),
'constant.language.jinja2': 'false|False|none|None|null|true|True',
},
'identifier',
)
const jinjaExpressionRules = [
{
token: 'string',
regex: "'",
push: 'jinja2-qstring',
},
{
token: 'string',
regex: '"',
push: 'jinja2-qqstring',
},
{
token: 'constant.numeric',
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
},
{
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\|)(\\s*)(${filters})\\b`,
},
{
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
},
{
token: ['support.function.jinja2', 'text', 'paren.lparen'],
regex: `\\b(${functions})(\\s*)(\\()`,
},
{
token: 'variable.language.jinja2',
regex: `\\b(?:${contextVariables})\\b`,
},
{
token: keywordMapper,
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
},
{
token: 'keyword.operator.assignment.jinja2',
regex: /=|~/,
},
{
token: 'keyword.operator.comparison.jinja2',
regex: /==|!=|<=|>=|<|>/,
},
{
token: 'keyword.operator.arithmetic.jinja2',
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
},
{
token: 'keyword.operator.other.jinja2',
regex: /\.{2}|\||:/,
},
{
token: 'punctuation.operator.jinja2',
regex: /[.,;?]/,
},
{
token: 'paren.lparen',
regex: /[\[({]/,
},
{
token: 'paren.rparen',
regex: /[\])}]/,
},
{
token: 'text',
regex: /\s+/,
},
]
this.$rules = {
start: [
{
token: 'comment.block.jinja2',
regex: /\{#-?/,
push: 'jinja2-comment',
},
{
token: 'constant.language.jinja2',
regex: /\{\{-?/,
push: 'jinja2-expression',
},
{
token: 'keyword.control.jinja2',
regex: /\{%-?/,
push: 'jinja2-statement',
},
],
'jinja2-comment': [
{
token: 'comment.block.jinja2',
regex: /-?#\}/,
next: 'pop',
},
{
defaultToken: 'comment.block.jinja2',
},
],
'jinja2-expression': [
{
token: 'constant.language.jinja2',
regex: /-?\}\}/,
next: 'pop',
},
...jinjaExpressionRules,
],
'jinja2-statement': [
{
token: 'keyword.control.jinja2',
regex: /-?%\}/,
next: 'pop',
},
...jinjaExpressionRules,
],
'jinja2-qqstring': [
{
token: 'constant.language.escape',
regex: /\\[\\"ntr]/,
},
{
token: 'string',
regex: '"',
next: 'pop',
},
{
defaultToken: 'string',
},
],
'jinja2-qstring': [
{
token: 'constant.language.escape',
regex: /\\[\\'ntr]/,
},
{
token: 'string',
regex: "'",
next: 'pop',
},
{
defaultToken: 'string',
},
],
}
this.normalizeRules()
}
oop.inherits(Jinja2HighlightRules, TextHighlightRules)
exports.Jinja2HighlightRules = Jinja2HighlightRules
},
)
aceModule.define?.(
'ace/mode/jinja2',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const Jinja2HighlightRules = require('./jinja2_highlight_rules').Jinja2HighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = Jinja2HighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.$id = 'ace/mode/jinja2'
this.blockComment = { start: '{#', end: '#}' }
}).call(Mode.prototype)
exports.Mode = Mode
},
)
aceModule.define?.('ace/snippets/jinja2', ['require', 'exports', 'module'], (_require: any, exports: any) => {
exports.snippetText =
'snippet if\n\t{% if ${1:condition} %}\n\t\t${0}\n\t{% endif %}\n' +
'snippet for\n\t{% for ${1:item} in ${2:items} %}\n\t\t${0}\n\t{% endfor %}\n' +
'snippet var\n\t{{ ${1:name} }}\n'
exports.scope = 'jinja2'
})
aceModule.define?.(
'ace/mode/jinja2_json_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 Jinja2JsonHighlightRules = function (this: any) {
const tags =
'autoescape|block|call|do|elif|else|endautoescape|endblock|endcall|endfilter|endfor|endif|endmacro|endraw|endset|endtrans|endwith|extends|filter|for|from|if|import|include|macro|raw|set|trans|with'
const filters =
'abs|attr|batch|capitalize|center|count|d|default|dictsort|e|escape|filesizeformat|first|float|forceescape|format|groupby|indent|int|items|join|last|length|list|lower|map|max|min|pprint|random|reject|rejectattr|replace|reverse|round|safe|select|selectattr|slice|sort|string|striptags|sum|title|tojson|trim|truncate|unique|upper|urlencode|urlize|wordcount|wordwrap|xmlattr'
const functions = 'cycler|dict|joiner|lipsum|namespace|range'
const tests =
'boolean|defined|divisibleby|eq|escaped|even|false|filter|float|ge|gt|in|integer|iterable|le|lower|lt|mapping|ne|none|number|odd|sameas|sequence|string|test|true|undefined|upper'
const operators = 'and|in|is|not|or'
const contextVariables =
'title|en_title|original_title|season|season_fmt|year|title_year|type|category|vote_average|poster|backdrop|season_year|actors|overview|tmdbid|imdbid|doubanid|episode_title|episode_date|original_name|name|en_name|episode|season_episode|part|customization|fps|resourceType|effect|edition|videoFormat|resource_term|releaseGroup|videoCodec|audioCodec|webSource|torrent_title|pubdate|freedate|seeders|volume_factor|hit_and_run|labels|description|site_name|size|transfer_type|file_count|total_size|err_msg|fileExt|__meta__|__mediainfo__|__torrentinfo__|__transferinfo__|__episodes_info__'
const keywordMapper = this.createKeywordMapper(
{
'keyword.control.jinja2': tags,
'keyword.operator.jinja2': operators,
'support.function.jinja2': [filters, functions, tests].join('|'),
'constant.language.jinja2': 'false|False|none|None|null|true|True',
},
'identifier',
)
const jinjaRules = [
{
token: 'string',
regex: "'",
push: 'jinja2-json-qstring',
},
{
token: 'constant.language.escape',
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
},
{
token: 'constant.numeric',
regex: /[+-]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\b/,
},
{
token: ['keyword.operator.other.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\|)(\\s*)(${filters})\\b`,
},
{
token: ['keyword.operator.jinja2', 'text', 'support.function.jinja2'],
regex: `(\\bis\\b)(\\s*)(${tests})\\b`,
},
{
token: ['support.function.jinja2', 'text', 'paren.lparen'],
regex: `\\b(${functions})(\\s*)(\\()`,
},
{
token: 'variable.language.jinja2',
regex: `\\b(?:${contextVariables})\\b`,
},
{
token: keywordMapper,
regex: /[a-zA-Z_$][a-zA-Z0-9_$]*\b/,
},
{
token: 'keyword.operator.assignment.jinja2',
regex: /=|~/,
},
{
token: 'keyword.operator.comparison.jinja2',
regex: /==|!=|<=|>=|<|>/,
},
{
token: 'keyword.operator.arithmetic.jinja2',
regex: /\+|-|\/\/|\/|%|\*\*|\*/,
},
{
token: 'keyword.operator.other.jinja2',
regex: /\.{2}|\||:/,
},
{
token: 'punctuation.operator.jinja2',
regex: /[.,;?]/,
},
{
token: 'paren.lparen',
regex: /[\[({]/,
},
{
token: 'paren.rparen',
regex: /[\])}]/,
},
{
token: 'text',
regex: /\s+/,
},
]
this.$rules = {
start: [
{
token: 'variable',
regex: /"(?:(?:\\.)|(?:[^"\\]))*?"\s*(?=:)/,
},
{
token: 'string',
regex: '"',
push: 'json-string',
},
{
token: 'constant.numeric',
regex: /0[xX][0-9a-fA-F]+\b/,
},
{
token: 'constant.numeric',
regex: /[+-]?\d+(?:(?:\.\d*)?(?:[eE][+-]?\d+)?)?\b/,
},
{
token: 'constant.language.boolean',
regex: /(?:true|false|null)\b/,
},
{
token: 'text',
regex: /['](?:(?:\\.)|(?:[^'\\]))*?[']/,
},
{
token: 'comment',
regex: /\/\/.*$/,
},
{
token: 'comment.start',
regex: /\/\*/,
push: 'comment',
},
{
token: 'paren.lparen',
regex: /[[({]/,
},
{
token: 'paren.rparen',
regex: /[\])}]/,
},
{
token: 'punctuation.operator',
regex: /[:,]/,
},
{
token: 'text',
regex: /\s+/,
},
],
'json-string': [
{
token: 'constant.language.escape',
regex: /\\(?:x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|["\\\/bfnrt])/,
},
{
token: 'comment.block.jinja2',
regex: /\{#-?/,
push: 'jinja2-json-comment',
},
{
token: 'constant.language.jinja2',
regex: /\{\{-?/,
push: 'jinja2-json-expression',
},
{
token: 'keyword.control.jinja2',
regex: /\{%-?/,
push: 'jinja2-json-statement',
},
{
token: 'string',
regex: /"|$/,
next: 'pop',
},
{
defaultToken: 'string',
},
],
comment: [
{
token: 'comment.end',
regex: /\*\//,
next: 'pop',
},
{
defaultToken: 'comment',
},
],
'jinja2-json-comment': [
{
token: 'comment.block.jinja2',
regex: /-?#\}/,
next: 'pop',
},
{
defaultToken: 'comment.block.jinja2',
},
],
'jinja2-json-expression': [
{
token: 'constant.language.jinja2',
regex: /-?\}\}/,
next: 'pop',
},
...jinjaRules,
],
'jinja2-json-statement': [
{
token: 'keyword.control.jinja2',
regex: /-?%\}/,
next: 'pop',
},
...jinjaRules,
],
'jinja2-json-qstring': [
{
token: 'constant.language.escape',
regex: /\\[\\'ntr]/,
},
{
token: 'string',
regex: "'",
next: 'pop',
},
{
defaultToken: 'string',
},
],
}
this.normalizeRules()
}
oop.inherits(Jinja2JsonHighlightRules, TextHighlightRules)
exports.Jinja2JsonHighlightRules = Jinja2JsonHighlightRules
},
)
aceModule.define?.(
'ace/mode/jinja2_json',
['require', 'exports', 'module', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/jinja2_json_highlight_rules'],
(require: any, exports: any) => {
const oop = require('../lib/oop')
const TextMode = require('./text').Mode
const Jinja2JsonHighlightRules = require('./jinja2_json_highlight_rules').Jinja2JsonHighlightRules
const Mode = function (this: any) {
TextMode.call(this)
this.HighlightRules = Jinja2JsonHighlightRules
}
oop.inherits(Mode, TextMode)
;(function (this: any) {
this.lineCommentStart = '//'
this.blockComment = { start: '/*', end: '*/' }
this.$id = 'ace/mode/jinja2_json'
}).call(Mode.prototype)
exports.Mode = Mode
},
)
}
ace.config.setModuleUrl('ace/mode/json', modeJsonUrl)
ace.config.setModuleUrl('ace/mode/javascript', modeJavascriptUrl)
ace.config.setModuleUrl('ace/mode/html', modeHtmlUrl)
@@ -61,9 +543,10 @@ ace.config.setModuleUrl('ace/mode/yaml_worker', workerYamlUrl)
ace.config.setModuleUrl('ace/mode/css_worker', workerCssUrl)
ace.config.setModuleUrl('ace/snippets/html', snippetsHtmlUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsJsUrl)
ace.config.setModuleUrl('ace/snippets/javascript', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/yaml', snippetsYamlUrl)
ace.config.setModuleUrl('ace/snippets/json', snippetsJsonUrl)
ace.config.setModuleUrl('ace/snippets/css', snippertsCssUrl)
ace.config.setModuleUrl('ace/snippets/ini', snippertsIniUrl)
registerJinja2Mode()
ace.require('ace/ext/language_tools')

View File

@@ -101,19 +101,21 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="openRuleInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.rule.name }}</h5>
<div class="text-body-1 mb-3">{{ props.rule.id }}</div>
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.rule.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_svg" contain class="app-card-summary__image" />
</div>
<VImg :src="filter_svg" cover class="mt-7" max-width="3rem" />
</VCardText>
</VCard>
<VDialog

View File

@@ -195,7 +195,7 @@ watch(
</script>
<template>
<VCard variant="tonal" :width="props.width" :height="props.height">
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField
@@ -204,8 +204,8 @@ watch(
:label="t('directory.alias')"
class="me-20 text-high-emphasis font-weight-bold"
/>
<span class="absolute top-3 right-12">
<IconBtn>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>

View File

@@ -252,18 +252,19 @@ onUnmounted(() => {
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="absolute top-3 right-12">
<IconBtn>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VCardText class="flex justify-space-between align-center gap-4">
<div class="align-self-start flex-1">
<div class="flex items-center">
<VCardText class="app-card-summary app-card-summary--double-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge
v-if="props.downloader.default && props.downloader.enabled"
dot
@@ -271,18 +272,21 @@ onUnmounted(() => {
color="success"
class="me-1"
/>
<span class="text-h6">{{ downloader.name }}</span>
<span class="app-card-summary__title text-h6">{{ downloader.name }}</span>
</div>
<div v-if="downloaderDict[downloader.type] && props.downloader.enabled" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">{{ `${formatFileSize(upload_rate, 1)}/s ` }}</span>
<span>{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
<div
v-if="downloaderDict[downloader.type] && props.downloader.enabled"
class="app-card-summary__meta text-sm"
>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(upload_rate, 1)}/s` }}</span>
<span class="app-card-summary__meta-item">{{ `${formatFileSize(download_rate, 1)}/s` }}</span>
</div>
<div v-else-if="!downloaderDict[downloader.type]" class="mt-1 flex flex-wrap text-sm">
<span class="me-2">自定义下载器</span>
<div v-else-if="!downloaderDict[downloader.type]" class="app-card-summary__subtitle text-sm">
自定义下载器
</div>
</div>
<div class="h-20">
<VImg :src="getIcon" cover class="mt-8 me-3" max-width="3rem" min-width="3rem" />
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
@@ -342,11 +346,23 @@ onUnmounted(() => {
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
@@ -358,6 +374,7 @@ onUnmounted(() => {
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"

View File

@@ -45,15 +45,15 @@ onMounted(() => {
</script>
<template>
<VCard variant="tonal">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VCardTitle>{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VCardTitle class="pr-8">{{ t('filterRule.priority') }} {{ props.pri }}</VCardTitle>
<VRow>
<VCol>
<VAutocomplete

View File

@@ -205,22 +205,24 @@ function onClose() {
<template>
<div>
<VCard variant="tonal" @click="opengroupInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell" @click="opengroupInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<h5 class="text-h6 mb-1">{{ props.group.name }}</h5>
<div class="text-body-1 mb-3">
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<h5 class="app-card-summary__title text-h6">{{ props.group.name }}</h5>
<div class="app-card-summary__subtitle text-body-1">
<span v-if="!props.group.category">{{ props.group.media_type || t('common.all') }}</span>
<span v-else>{{ props.group.category }}</span>
</div>
</div>
<VImg :src="filter_group_svg" cover class="mt-10" max-width="3rem" />
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>
<VDialog

View File

@@ -199,21 +199,27 @@ onMounted(() => {
</script>
<template>
<div>
<VCard variant="tonal" @click="openMediaServerInfoDialog">
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<div class="text-h6 mb-1">{{ mediaserver.name }}</div>
<div v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled" class="text-sm mt-5 flex flex-wrap">
<span v-for="item in infoItems" :key="item.title" class="me-2 mb-1">
<VIcon rounded :icon="item.avatar" class="me-1" />{{ item.amount }}
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
<div class="app-card-summary__title text-h6">{{ mediaserver.name }}</div>
<div
v-if="mediaServerDict[mediaserver.type] && mediaserver.enabled"
class="grid min-h-6 grid-cols-3 gap-2 text-sm text-medium-emphasis"
>
<span v-for="item in infoItems" :key="item.title" class="flex min-w-0 items-center">
<VIcon rounded :icon="item.avatar" class="me-1 shrink-0" />
<span class="truncate">{{ item.amount }}</span>
</span>
</div>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="text-sm mt-5 flex flex-wrap">
<span class="me-2 mb-1">自定义媒体服务器</span>
<div v-else-if="!mediaServerDict[mediaserver.type]" class="app-card-summary__subtitle text-sm">
自定义媒体服务器
</div>
</div>
<VImg :src="getIcon" class="mt-8 me-3 max-h-12" max-width="3rem" min-width="3rem" />
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
</VCardText>
</VCard>

View File

@@ -153,22 +153,24 @@ function onClose() {
</script>
<template>
<div>
<VCard variant="tonal" @click="openNotificationInfoDialog">
<span class="absolute top-3 right-12">
<IconBtn>
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
</IconBtn>
</span>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start">
<div class="flex items-center">
<VCardText class="app-card-summary app-card-summary--double-action app-card-summary--title-subtitle">
<div class="app-card-summary__content">
<div class="app-card-summary__title-row">
<VBadge v-if="props.notification.enabled" dot inline color="success" class="me-1" />
<span class="text-h6">{{ props.notification.name }}</span>
<span class="app-card-summary__title text-h6">{{ props.notification.name }}</span>
</div>
<div class="text-body-1 mb-3">{{ notificationTypeNames[notification.type] }}</div>
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
</div>
<VImg :src="getIcon" cover class="mt-7 me-1" max-width="3rem" />
</VCardText>
</VCard>

View File

@@ -566,13 +566,13 @@ watch(
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="60rem"
:fullscreen="!display.mdAndUp.value"
>
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="72rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
@@ -588,7 +588,7 @@ watch(
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
</VCardText>
</VCard>

View File

@@ -216,11 +216,17 @@ onMounted(() => {
<div v-if="cardProps.site?.is_active" class="site-status-indicator" :class="statColor"></div>
<!-- 主体部分 -->
<div class="relative flex-1 flex flex-col p-3 z-1">
<div class="relative z-1 flex flex-1 flex-col p-3 pr-12">
<!-- 顶部图标和站点名称 -->
<div class="flex items-center mb-1">
<div class="mb-1 flex min-w-0 items-center gap-2">
<!-- 站点图标 -->
<VAvatar tile rounded="lg" size="32" class="me-2" :class="{ 'cursor-move': display.mdAndUp.value }">
<VAvatar
tile
rounded="lg"
size="32"
class="shrink-0"
:class="{ 'cursor-move': display.mdAndUp.value }"
>
<VImg :src="siteIcon" class="w-full h-full" :alt="cardProps.site?.name" cover>
<template #placeholder>
<div class="w-full h-full">
@@ -231,11 +237,11 @@ onMounted(() => {
</VAvatar>
<!-- 站点名称和特性图标 -->
<div class="flex-1 min-w-0 flex items-center">
<h3 class="text-lg font-semibold leading-tight truncate">{{ cardProps.site?.name }}</h3>
<div class="flex min-w-0 flex-1 items-center gap-2">
<h3 class="min-w-0 flex-1 truncate text-lg font-semibold leading-tight">{{ cardProps.site?.name }}</h3>
<!-- 站点特性图标 -->
<div class="flex items-center gap-2 ml-auto mr-10">
<div class="ml-auto flex shrink-0 items-center gap-2">
<div v-if="cardProps.site?.limit_interval" class="hover:bg-primary/8 transition-colors">
<VIcon icon="mdi-speedometer" size="16" color="primary" class="opacity-85 hover:opacity-100" />
</div>
@@ -254,7 +260,7 @@ onMounted(() => {
<!-- 中间部分网址 -->
<div class="my-3">
<div class="text-sm text-medium-emphasis truncate" @click.stop="openSitePage">
<div class="min-w-0 truncate text-sm text-medium-emphasis" @click.stop="openSitePage">
{{ cardProps.site?.url }}
</div>
</div>

View File

@@ -0,0 +1,380 @@
import { computed, onBeforeUnmount, ref, type Ref } from 'vue'
import api from '@/api'
export interface LlmProviderAuthMethod {
id: string
type: string
label: string
description?: string
}
export interface LlmProviderAuthStatus {
connected: boolean
type?: string
label?: string
expires_at?: number | null
updated_at?: number | null
}
export interface LlmProviderUrlPreset {
label: string
value: string
}
export interface LlmProviderUrlPresetItem {
title: string
value: string
subtitle?: string
}
export interface LlmProvider {
id: string
name: string
runtime: string
default_base_url: string
base_url_presets?: LlmProviderUrlPreset[]
base_url_editable: boolean
requires_base_url: boolean
supports_api_key: boolean
api_key_label: string
api_key_hint: string
supports_model_refresh: boolean
oauth_methods: LlmProviderAuthMethod[]
description?: string
auth_status: LlmProviderAuthStatus
}
export interface LlmModel {
id: string
name: string
family?: string
context_tokens?: number | null
input_tokens?: number | null
output_tokens?: number | null
context_tokens_k?: number | null
supports_reasoning?: boolean
supports_tools?: boolean
supports_image_input?: boolean
supports_audio_input?: boolean
transport?: string
source?: string
release_date?: string | null
status?: string | null
}
export interface LlmProviderAuthSession {
session_id: string
provider_id: string
flow_type: string
status: string
message?: string
authorize_url?: string
verification_url?: string
user_code?: string
instructions?: string
interval_seconds?: number
expires_at?: number
}
interface UseLlmProviderDirectoryOptions {
provider: Ref<string>
apiKey: Ref<string>
baseUrl: Ref<string>
model: Ref<string>
maxContextTokens?: Ref<number>
authConnected?: Ref<boolean>
}
function normalizeValue(value: unknown) {
return String(value ?? '').trim()
}
export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions) {
const providers = ref<LlmProvider[]>([])
const models = ref<LlmModel[]>([])
const loadingProviders = ref(false)
const loadingModels = ref(false)
const authDialogVisible = ref(false)
const authPolling = ref(false)
const authPopupBlocked = ref(false)
const authSession = ref<LlmProviderAuthSession | null>(null)
let pollTimer: number | null = null
const selectedProvider = computed(
() => providers.value.find(item => item.id === normalizeValue(options.provider.value)) || null,
)
const selectedModel = computed(
() => models.value.find(item => item.id === normalizeValue(options.model.value)) || null,
)
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
(selectedProvider.value?.base_url_presets || []).map(item => ({
title: item.value,
value: item.value,
subtitle: item.label,
})),
)
const providerConnected = computed(() => Boolean(selectedProvider.value?.auth_status?.connected))
const showBaseUrlField = computed(
() => Boolean(selectedProvider.value && (selectedProvider.value.oauth_methods || []).length === 0),
)
const showApiKeyField = computed(() => selectedProvider.value?.supports_api_key !== false)
const hasUsableCredential = computed(() => {
if (providerConnected.value) return true
return Boolean(normalizeValue(options.apiKey.value))
})
const canRefreshModels = computed(() => {
if (!selectedProvider.value?.supports_model_refresh) return false
if (!hasUsableCredential.value) return false
if (selectedProvider.value.requires_base_url && !normalizeValue(options.baseUrl.value)) return false
return true
})
function clearPollTimer() {
if (pollTimer !== null) {
window.clearTimeout(pollTimer)
pollTimer = null
}
}
function syncAuthConnected() {
if (options.authConnected) {
options.authConnected.value = providerConnected.value
}
}
function ensureBaseUrl(reset = false) {
const provider = selectedProvider.value
if (!provider) return
const currentBaseUrl = normalizeValue(options.baseUrl.value)
const defaultBaseUrl = provider.default_base_url || ''
if (reset) {
options.baseUrl.value = defaultBaseUrl
return
}
if (!currentBaseUrl && defaultBaseUrl) {
options.baseUrl.value = defaultBaseUrl
}
}
function handleProviderSelection(resetBaseUrl = true) {
ensureBaseUrl(resetBaseUrl)
options.apiKey.value = ''
if (options.maxContextTokens) {
options.maxContextTokens.value = 64
}
models.value = []
options.model.value = ''
syncAuthConnected()
}
function applyModelMetadata(modelId?: string) {
const targetId = normalizeValue(modelId ?? options.model.value)
if (!targetId) return null
const matched = models.value.find(item => item.id === targetId) || null
if (matched?.context_tokens_k && options.maxContextTokens) {
// models.dev / provider 返回的是精确 token这里回填到现有的 K 单位配置。
options.maxContextTokens.value = matched.context_tokens_k
}
return matched
}
function updateProviderAuthStatus(providerId: string, authStatus?: LlmProviderAuthStatus) {
if (!authStatus) return
const index = providers.value.findIndex(item => item.id === providerId)
if (index === -1) return
providers.value[index] = {
...providers.value[index],
auth_status: authStatus,
}
syncAuthConnected()
}
async function loadProviders(preserveBaseUrl = true) {
loadingProviders.value = true
try {
const result: { [key: string]: any } = await api.get('llm/providers')
if (!result.success) {
throw new Error(result.message || 'Load LLM providers failed')
}
providers.value = Array.isArray(result.data) ? result.data : []
if (!selectedProvider.value && providers.value.length > 0) {
options.provider.value = providers.value[0].id
}
ensureBaseUrl(!preserveBaseUrl)
syncAuthConnected()
return providers.value
} finally {
loadingProviders.value = false
}
}
async function loadModels(forceRefresh = false) {
if (!selectedProvider.value) return []
loadingModels.value = true
try {
const result: { [key: string]: any } = await api.get('llm/models', {
params: {
provider: normalizeValue(options.provider.value),
api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined,
force_refresh: forceRefresh,
},
})
if (!result.success) {
throw new Error(result.message || 'Load LLM models failed')
}
const payload = result.data || {}
models.value = Array.isArray(payload.models) ? payload.models : []
updateProviderAuthStatus(normalizeValue(options.provider.value), payload.auth_status)
const currentModelId = normalizeValue(options.model.value)
const matchedModel = currentModelId
? models.value.find(item => item.id === currentModelId)
: null
if (matchedModel) {
applyModelMetadata(matchedModel.id)
} else if (models.value.length > 0) {
options.model.value = models.value[0].id
applyModelMetadata(models.value[0].id)
}
return models.value
} finally {
loadingModels.value = false
}
}
function openAuthPage() {
const session = authSession.value
const targetUrl = session?.authorize_url || session?.verification_url
if (!targetUrl) return
const popup = window.open(targetUrl, '_blank', 'noopener,noreferrer,width=960,height=780')
authPopupBlocked.value = !popup
}
async function pollAuthSession() {
if (!authSession.value) return null
authPolling.value = true
clearPollTimer()
try {
const result: { [key: string]: any } = await api.post(
`llm/provider-auth/${authSession.value.session_id}/poll`,
)
if (!result.success) {
throw new Error(result.message || 'Poll LLM auth failed')
}
authSession.value = {
...authSession.value,
...result.data,
}
const nextSession = authSession.value
if (!nextSession) return null
if (nextSession.status === 'pending') {
pollTimer = window.setTimeout(
() => pollAuthSession().catch(() => undefined),
Math.max(nextSession.interval_seconds || 5, 1) * 1000,
)
return nextSession
}
await loadProviders()
if (nextSession.status === 'authorized') {
await loadModels(true).catch(() => undefined)
}
return nextSession
} finally {
authPolling.value = false
}
}
async function startAuth(methodId: string) {
if (!selectedProvider.value) {
throw new Error('LLM provider is required')
}
const result: { [key: string]: any } = await api.post('llm/provider-auth/start', {
provider: normalizeValue(options.provider.value),
method: methodId,
})
if (!result.success) {
throw new Error(result.message || 'Start LLM auth failed')
}
authSession.value = {
status: 'pending',
provider_id: normalizeValue(options.provider.value),
...result.data,
}
authDialogVisible.value = true
authPopupBlocked.value = false
openAuthPage()
pollTimer = window.setTimeout(() => pollAuthSession().catch(() => undefined), 1200)
return authSession.value
}
async function disconnectAuth() {
if (!selectedProvider.value) return false
const result: { [key: string]: any } = await api.delete(
`llm/provider-auth/${normalizeValue(options.provider.value)}`,
)
if (!result.success) {
throw new Error(result.message || 'Disconnect LLM auth failed')
}
await loadProviders()
return true
}
function closeAuthDialog() {
authDialogVisible.value = false
clearPollTimer()
}
onBeforeUnmount(() => {
clearPollTimer()
})
return {
providers,
providerItems,
baseUrlPresetItems,
models,
selectedProvider,
selectedModel,
loadingProviders,
loadingModels,
providerConnected,
showBaseUrlField,
showApiKeyField,
hasUsableCredential,
canRefreshModels,
authDialogVisible,
authPolling,
authPopupBlocked,
authSession,
handleProviderSelection,
applyModelMetadata,
loadProviders,
loadModels,
openAuthPage,
startAuth,
pollAuthSession,
disconnectAuth,
closeAuthDialog,
}
}

View File

@@ -53,11 +53,21 @@ export interface WizardData {
global: boolean
verbose: boolean
provider: string
authConnected: boolean
model: string
thinkingLevel: string
supportImageInput: boolean
supportAudioInputOutput: boolean
apiKey: string
baseUrl: string
maxContextTokens: number
voiceApiKey: string
voiceBaseUrl: string
voiceSttModel: string
voiceTtsModel: string
voiceTtsVoice: string
voiceLanguage: string
voiceReplyWithText: boolean
jobInterval: number
retryTransfer: boolean
recommendEnabled: boolean
@@ -97,6 +107,7 @@ export interface ValidationErrorState {
downloader: {
name: boolean
host: boolean
apikey: boolean
username: boolean
password: boolean
}
@@ -121,6 +132,33 @@ export interface ValidationErrorState {
}
}
function normalizeThinkingLevelValue(value?: unknown) {
const normalized = String(value ?? '').trim().toLowerCase()
if (!normalized) return ''
const aliasMap: Record<string, string> = {
none: 'off',
disabled: 'off',
disable: 'off',
enabled: 'auto',
enable: 'auto',
default: 'auto',
dynamic: 'auto',
}
return aliasMap[normalized] || normalized
}
function resolveThinkingLevelValue(data?: Record<string, any>) {
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
if (explicit) return explicit
const legacyEffort = normalizeThinkingLevelValue(data?.LLM_REASONING_EFFORT)
if (data?.LLM_DISABLE_THINKING === true) return 'off'
if (data?.LLM_DISABLE_THINKING === false) return legacyEffort || 'auto'
return legacyEffort || 'off'
}
// 全局状态,所有组件共享
const currentStep = ref(1)
const totalSteps = 8
@@ -195,11 +233,21 @@ const wizardData = ref<WizardData>({
global: false,
verbose: false,
provider: 'deepseek',
authConnected: false,
model: 'deepseek-chat',
thinkingLevel: 'off',
supportImageInput: true,
supportAudioInputOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
maxContextTokens: 64,
voiceApiKey: '',
voiceBaseUrl: '',
voiceSttModel: 'gpt-4o-mini-transcribe',
voiceTtsModel: 'gpt-4o-mini-tts',
voiceTtsVoice: 'alloy',
voiceLanguage: 'zh',
voiceReplyWithText: false,
jobInterval: 0,
retryTransfer: false,
recommendEnabled: false,
@@ -230,6 +278,7 @@ const validationErrors = ref<ValidationErrorState>({
downloader: {
name: false,
host: false,
apikey: false,
username: false,
password: false,
},
@@ -419,6 +468,7 @@ export function useSetupWizard() {
validationErrors.value.downloader = {
name: false,
host: false,
apikey: false,
username: false,
password: false,
}
@@ -501,9 +551,18 @@ export function useSetupWizard() {
}
// 根据下载器类型验证其他必输项
if (
wizardData.value.downloader.type === 'qbittorrent'
|| wizardData.value.downloader.type === 'transmission'
if (wizardData.value.downloader.type === 'qbittorrent') {
const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
if (!hasApiKey && !wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
}
if (!hasApiKey && !wizardData.value.downloader.config?.password?.trim()) {
errors.push(t('downloader.passwordRequired'))
validationErrors.value.downloader.password = true
}
} else if (
wizardData.value.downloader.type === 'transmission'
|| wizardData.value.downloader.type === 'rtorrent'
) {
if (!wizardData.value.downloader.config?.username?.trim()) {
@@ -672,8 +731,8 @@ export function useSetupWizard() {
validationErrors.value.agent.provider = true
}
if (!wizardData.value.agent.apiKey?.trim()) {
errors.push(t('setupWizard.agent.apiKeyRequired'))
if (!wizardData.value.agent.apiKey?.trim() && !wizardData.value.agent.authConnected) {
errors.push(t('setupWizard.agent.authOrApiKeyRequired'))
validationErrors.value.agent.apiKey = true
}
@@ -1332,10 +1391,19 @@ export function useSetupWizard() {
AI_AGENT_VERBOSE: wizardData.value.agent.enabled ? wizardData.value.agent.verbose : false,
LLM_PROVIDER: wizardData.value.agent.provider,
LLM_MODEL: wizardData.value.agent.model,
LLM_THINKING_LEVEL: wizardData.value.agent.thinkingLevel,
LLM_SUPPORT_IMAGE_INPUT: wizardData.value.agent.supportImageInput,
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
AI_VOICE_STT_MODEL: wizardData.value.agent.voiceSttModel,
AI_VOICE_TTS_MODEL: wizardData.value.agent.voiceTtsModel,
AI_VOICE_TTS_VOICE: wizardData.value.agent.voiceTtsVoice,
AI_VOICE_LANGUAGE: wizardData.value.agent.voiceLanguage,
AI_VOICE_REPLY_WITH_TEXT: wizardData.value.agent.voiceReplyWithText,
AI_AGENT_JOB_INTERVAL: wizardData.value.agent.enabled ? wizardData.value.agent.jobInterval : 0,
AI_AGENT_RETRY_TRANSFER: wizardData.value.agent.enabled ? wizardData.value.agent.retryTransfer : false,
AI_RECOMMEND_ENABLED:
@@ -1428,11 +1496,21 @@ export function useSetupWizard() {
wizardData.value.agent.global = Boolean(result.data.AI_AGENT_GLOBAL)
wizardData.value.agent.verbose = Boolean(result.data.AI_AGENT_VERBOSE)
wizardData.value.agent.provider = result.data.LLM_PROVIDER || 'deepseek'
wizardData.value.agent.authConnected = false
wizardData.value.agent.model = result.data.LLM_MODEL || ''
wizardData.value.agent.thinkingLevel = resolveThinkingLevelValue(result.data)
wizardData.value.agent.supportImageInput = result.data.LLM_SUPPORT_IMAGE_INPUT ?? true
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''
wizardData.value.agent.voiceSttModel = result.data.AI_VOICE_STT_MODEL || 'gpt-4o-mini-transcribe'
wizardData.value.agent.voiceTtsModel = result.data.AI_VOICE_TTS_MODEL || 'gpt-4o-mini-tts'
wizardData.value.agent.voiceTtsVoice = result.data.AI_VOICE_TTS_VOICE || 'alloy'
wizardData.value.agent.voiceLanguage = result.data.AI_VOICE_LANGUAGE || 'zh'
wizardData.value.agent.voiceReplyWithText = Boolean(result.data.AI_VOICE_REPLY_WITH_TEXT)
wizardData.value.agent.jobInterval = result.data.AI_AGENT_JOB_INTERVAL || 0
wizardData.value.agent.retryTransfer = Boolean(result.data.AI_AGENT_RETRY_TRANSFER)
wizardData.value.agent.recommendEnabled = Boolean(result.data.AI_RECOMMEND_ENABLED)

View File

@@ -14,6 +14,12 @@ import { getQueryValue } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { clearAppBadge } from '@/utils/badge'
type MessageViewExpose = {
pauseSSE?: () => void
resumeSSE?: () => void
refreshLatestMessages?: () => Promise<void> | void
}
// 国际化
const { t } = useI18n()
@@ -63,7 +69,7 @@ const sendButtonDisabled = ref(false)
const messageDialogRef = ref<any>(null)
// 消息视图引用
const messageViewRef = ref<any>(null)
const messageViewRef = ref<MessageViewExpose | null>(null)
// 滚动容器引用
const messageContentRef = ref<any>()
@@ -153,9 +159,7 @@ async function openMessageDialog() {
}, 600)
// 等待对话框打开后恢复SSE连接
nextTick(() => {
if (messageViewRef.value && typeof messageViewRef.value.resumeSSE === 'function') {
messageViewRef.value.resumeSSE()
}
messageViewRef.value?.resumeSSE?.()
})
}
@@ -203,16 +207,23 @@ function allLoggingUrl() {
// 发送消息
async function sendMessage() {
if (user_message.value) {
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${user_message.value}`)
user_message.value = ''
sendButtonDisabled.value = false
forceScrollToEnd() // 发送消息后强制滚动到底部
} catch (error) {
console.error(error)
}
const messageText = user_message.value.trim()
if (!messageText) {
return
}
try {
sendButtonDisabled.value = true
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
user_message.value = ''
// 发送成功后主动同步最新一页消息避免SSE短暂断流时界面停留在旧状态。
// await messageViewRef.value?.refreshLatestMessages?.()
forceScrollToEnd() // 发送消息后强制滚动到底部
} catch (error) {
console.error(error)
} finally {
sendButtonDisabled.value = false
}
}
@@ -228,7 +239,7 @@ defineExpose({
// 监听消息对话框状态变化
watch(messageDialog, newValue => {
if (!newValue && messageViewRef.value && typeof messageViewRef.value.pauseSSE === 'function') {
if (!newValue && messageViewRef.value?.pauseSSE) {
// 对话框关闭时暂停SSE连接
messageViewRef.value.pauseSSE()
}
@@ -350,13 +361,13 @@ onMounted(() => {
</VCard>
</VDialog>
<!-- 实时日志弹窗 -->
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="70rem"
:fullscreen="!display.mdAndUp.value"
>
<VDialog
v-if="loggingDialog"
v-model="loggingDialog"
scrollable
max-width="80rem"
:fullscreen="!display.mdAndUp.value"
>
<VCard>
<VDialogCloseBtn @click="loggingDialog = false" />
<VCardItem>
@@ -372,7 +383,7 @@ onMounted(() => {
</VCardTitle>
</VCardItem>
<VDivider />
<VCardText>
<VCardText class="pa-0">
<LoggingView logfile="moviepilot.log" />
</VCardText>
</VCard>

View File

@@ -460,7 +460,8 @@ export default {
botSecret: 'Bot Secret',
botSecretHint: 'WebSocket secret of the WeChat Work AI bot',
botChatId: 'Default Target',
botChatIdHint: 'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
botChatIdHint:
'Use user userid; for proactive group messages use group:chatid. Leave empty to notify known interacted users',
botChatIdPlaceholder: 'userid or group:chatid',
botWsUrl: 'WebSocket URL',
botWsUrlHint: 'WebSocket endpoint for the WeChat Work AI bot, usually the default value',
@@ -997,6 +998,7 @@ export default {
aiRecommend: 'AI Recommendation',
reRecommend: 'Regenerate Recommendation',
aiRecommendError: 'AI Recommendation Failed',
refreshSearch: 'Re-search',
},
browse: {
actor: 'Actor',
@@ -1232,6 +1234,17 @@ export default {
content: 'Content',
refreshing: 'Refreshing',
initializing: 'Initializing',
searchPlaceholder: 'Search logs',
allLevels: 'All Levels',
followTail: 'Follow latest logs',
wrapLines: 'Wrap lines',
pauseStream: 'Pause stream',
resumeStream: 'Resume stream',
waitingForLogs: 'Waiting for logs...',
paused: 'Paused',
connected: 'Live',
lineCount: 'Showing {visible}/{total} lines',
jumpToLatest: 'Jump to latest ({count})',
},
moduleTest: {
normal: 'Normal',
@@ -1322,10 +1335,31 @@ export default {
aiAgent: 'Enable AI Assistant',
aiAgentEnable: 'Enable AI Assistant',
aiAgentEnableHint: 'Enable AI assistant functionality, requires LLM configuration',
aiAgentSectionTitle: 'AI Assistant Configuration',
aiAgentSectionDesc:
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
llmProvider: 'LLM Provider',
llmProviderHint: 'Select the LLM service provider to use',
llmModel: 'LLM Model Name',
llmModelHint: 'Specify the LLM model to use, such as gpt-3.5-turbo, deepseek-chat, etc.',
llmModelHint: 'Specify the LLM model to use, such as deepseek-v4-flash, gpt-5.4, etc.',
llmModelResolvedHint: 'Max context has been auto-filled to {context}K from the model catalog. Source: {source}',
llmThinking: 'Thinking Mode / Depth',
llmThinkingHint:
'Thinking depth: off/auto/minimal/low/medium/high/max/xhigh. Unsupported levels will be mapped to the nearest provider-supported value.',
llmThinkingLevelOff: 'Off (off)',
llmThinkingLevelAuto: 'Auto (auto)',
llmThinkingLevelMinimal: 'Minimal (minimal)',
llmThinkingLevelLow: 'Low (low)',
llmThinkingLevelMedium: 'Medium (medium)',
llmThinkingLevelHigh: 'High (high)',
llmThinkingLevelMax: 'Max (max)',
llmThinkingLevelXhigh: 'XHigh (xhigh)',
llmSupportImageInput: 'Model Supports Image Input',
llmSupportImageInputHint:
'When enabled, message images are sent to the LLM as multimodal image input. When disabled, images are saved locally as attachments and only the file path is passed to the AI assistant.',
llmSupportAudioInputOutput: 'Support Audio Input and Output',
llmSupportAudioInputOutputHint:
'When enabled, the AI assistant can transcribe incoming audio messages and reply with voice on supported channels.',
llmMaxContextTokens: 'LLM Max Context Tokens (K)',
llmMaxContextTokensHint:
'Set the maximum number of context tokens (in thousands) for the LLM. Exceeding this limit will trigger context trimming.',
@@ -1334,6 +1368,39 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
llmProviderConnectedAs: 'Connected as: {label}',
llmProviderDisconnect: 'Disconnect Authorization',
llmProviderDisconnected: 'Provider authorization disconnected',
llmProviderAuthDialogTitle: 'Provider Authorization',
llmProviderPopupBlocked:
'The browser blocked the authorization popup. Use the button below to continue manually.',
llmProviderDeviceCode: 'Device Code',
llmProviderOpenAuthPage: 'Open Authorization Page',
llmProviderCheckAuthStatus: 'Check Authorization Status',
aiVoiceApiKey: 'Audio API Key',
aiVoiceApiKeyHint:
'API key used for audio transcription and speech synthesis. Falls back to the current LLM API key when left blank.',
aiVoiceBaseUrl: 'Audio Base URL',
aiVoiceBaseUrlHint:
'Base URL used for audio transcription and speech synthesis. Falls back to the current LLM base URL when left blank.',
aiVoiceSttModel: 'Audio Transcription Model',
aiVoiceSttModelHint: 'Model name used to convert audio content into text.',
aiVoiceTtsModel: 'Speech Synthesis Model',
aiVoiceTtsModelHint: 'Model name used to convert text content into speech.',
aiVoiceTtsVoice: 'Voice Preset',
aiVoiceTtsVoiceHint: 'Speaker or voice preset used for speech synthesis.',
aiVoiceLanguage: 'Recognition Language',
aiVoiceLanguageHint:
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
aiVoiceReplyWithText: 'Include Text with Voice Replies',
aiVoiceReplyWithTextHint: 'When sending a voice reply, also include the text version of the response.',
llmTestAction: 'Test Call',
llmTestSuccessToast: 'LLM test call succeeded',
llmTestFailedToast: 'LLM test call failed',
llmTestFailedToastWithMessage: 'LLM test call failed: {message}',
aiAgentGlobal: 'Global AI Assistant',
aiAgentGlobalHint:
'Enable global AI assistant functionality, all message conversations will be answered by the AI agent without using the /ai command',
@@ -1434,8 +1501,9 @@ export default {
fanartEnableHint: 'Use image data from fanart.tv',
fanartLang: 'Fanart Language',
fanartLangHint: 'Set language preference for Fanart images, ordered by priority when multiple selected',
recognizePluginFirst: "Prioritize Plugin Recognition",
recognizePluginFirstHint: "Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped",
recognizePluginFirst: 'Prioritize Plugin Recognition',
recognizePluginFirstHint:
'Prioritize calling plugins for media recognition. If a plugin matches, native recognition will be skipped',
githubProxy: 'Github Acceleration Proxy',
githubProxyPlaceholder: 'Leave empty for no proxy',
githubProxyHint: 'Use proxy to accelerate Github access speed',
@@ -1557,7 +1625,7 @@ export default {
skipDesc: 'Skip scraping, this file will not be generated',
missingOnlyDesc: 'Scrape only if missing, existing file remains unchanged',
overwriteDesc: 'Always scrape, existing file will be overwritten',
}
},
},
site: {
siteSync: 'Site Synchronization',
@@ -2801,6 +2869,7 @@ export default {
actions: {
aiRedo: 'Assistant Organize',
aiRedoPending: 'Assistant Organizing...',
batchAiRedo: 'Assistant Batch Organize',
redo: 'Reorganize',
delete: 'Delete',
batchRedo: 'Batch Reorganize',
@@ -2864,8 +2933,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
default: 'Default',
host: 'Host',
apiKey: 'API Key',
username: 'Username',
password: 'Password',
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
category: 'Auto Category Management',
sequentail: 'Sequential Download',
force_resume: 'Force Resume',
@@ -3224,7 +3295,8 @@ export default {
infoDesc:
'Completing site authentication unlocks site capabilities and some plugin permissions. This step is optional and can also be configured later from the user menu.',
selectSiteHint: 'Choose a supported auth site and fill in the required credentials for that site',
submitHint: 'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',
submitHint:
'When you click Next, the wizard will immediately validate against the selected auth site and save the current parameters on success.',
siteConfigNotExist: 'Authentication site configuration does not exist',
fieldRequired: 'Please enter {name}',
},
@@ -3300,6 +3372,7 @@ export default {
'After enabling it, you can use the Agent in message conversations and optionally turn on transfer-failure takeover and AI recommendations.',
providerRequired: 'LLM provider is required',
apiKeyRequired: 'LLM API key is required',
authOrApiKeyRequired: 'Provide an LLM API key or complete provider authorization first',
modelRequired: 'LLM model name is required',
maxContextTokensRequired: 'LLM max context tokens must be greater than 0',
recommendMaxItemsRequired: 'AI recommendation analysis limit must be greater than 0',

View File

@@ -993,6 +993,7 @@ export default {
aiRecommend: '智能推荐',
reRecommend: '重新生成推荐',
aiRecommendError: '智能推荐失败',
refreshSearch: '重新搜索',
},
browse: {
actor: '演员',
@@ -1228,6 +1229,17 @@ export default {
content: '内容',
refreshing: '正在刷新',
initializing: '正在初始化',
searchPlaceholder: '搜索日志内容',
allLevels: '全部级别',
followTail: '跟随最新日志',
wrapLines: '自动换行',
pauseStream: '暂停日志流',
resumeStream: '恢复日志流',
waitingForLogs: '等待日志输出...',
paused: '已暂停',
connected: '实时更新中',
lineCount: '显示 {visible}/{total} 行',
jumpToLatest: '查看最新 ({count})',
},
moduleTest: {
normal: '正常',
@@ -1317,13 +1329,30 @@ export default {
aiAgent: '启用智能助手',
aiAgentEnable: '启用智能助手',
aiAgentEnableHint: '启用后可使用智能助手功能需要配置LLM相关参数',
aiAgentSectionTitle: '智能助手配置',
aiAgentSectionDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
llmProvider: 'LLM提供商',
llmProviderHint: '选择使用的LLM服务提供商',
llmModel: 'LLM模型名称',
llmModelHint: '指定使用的LLM模型gpt-3.5-turbo、deepseek-chat等',
llmModelHint: '指定使用的LLM模型deepseek-v4-flash、gpt-5.4等',
llmModelResolvedHint: '已根据模型目录自动回填最大上下文为 {context}K来源{source}',
llmThinking: '思考模式 / 深度',
llmThinkingHint:
'思考深度off/auto/minimal/low/medium/high/max/xhigh不支持的级别会按 provider 能力自动映射到最近值',
llmThinkingLevelOff: '关闭 (off)',
llmThinkingLevelAuto: '自动 (auto)',
llmThinkingLevelMinimal: '最小 (minimal)',
llmThinkingLevelLow: '低 (low)',
llmThinkingLevelMedium: '中 (medium)',
llmThinkingLevelHigh: '高 (high)',
llmThinkingLevelMax: '极高 (max)',
llmThinkingLevelXhigh: '超高 (xhigh)',
llmSupportImageInput: '模型支持图片输入',
llmSupportImageInputHint:
'启用后,消息中的图片会按多模态图片发送给 LLM关闭后图片会作为附件保存到本地并将文件路径提供给智能助手处理',
llmSupportAudioInputOutput: '支持音频输入输出',
llmSupportAudioInputOutputHint:
'启用后,智能助手可以转写用户发送的音频消息,并在支持的渠道上回复语音',
llmMaxContextTokens: 'LLM 最大上下文 Token 数量 (K)',
llmMaxContextTokensHint:
'设定 LLM 记录会话历史的最大 Token 数量上限(千),超出后将自动修整历史记录以节省 Token 消耗及防止超出 LLM 限制',
@@ -1332,6 +1361,34 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
llmProviderAuth: '提供商授权',
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
llmProviderConnectedAs: '当前已连接:{label}',
llmProviderDisconnect: '断开授权',
llmProviderDisconnected: '已断开提供商授权',
llmProviderAuthDialogTitle: '提供商授权',
llmProviderPopupBlocked: '浏览器拦截了授权窗口,请手动点击下方按钮继续。',
llmProviderDeviceCode: '设备码',
llmProviderOpenAuthPage: '打开授权页面',
llmProviderCheckAuthStatus: '检查授权状态',
aiVoiceApiKey: '音频 API密钥',
aiVoiceApiKeyHint: '音频转写与语音合成使用的 API 密钥,留空时回退到当前 LLM API 密钥',
aiVoiceBaseUrl: '音频基础URL',
aiVoiceBaseUrlHint: '音频转写与语音合成接口的基础URL留空时回退到当前 LLM 基础 URL',
aiVoiceSttModel: '音频转写模型',
aiVoiceSttModelHint: '用于将音频内容转换为文字的模型名称',
aiVoiceTtsModel: '语音合成模型',
aiVoiceTtsModelHint: '用于将文字内容转换为语音的模型名称',
aiVoiceTtsVoice: '语音音色',
aiVoiceTtsVoiceHint: '语音合成使用的发音人或音色标识',
aiVoiceLanguage: '识别语言',
aiVoiceLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
aiVoiceReplyWithText: '语音回复附带文字',
aiVoiceReplyWithTextHint: '发送语音回复时,同时附带一份文字内容',
llmTestAction: '测试调用',
llmTestSuccessToast: 'LLM 调用测试成功',
llmTestFailedToast: 'LLM 调用测试失败',
llmTestFailedToastWithMessage: 'LLM 调用测试失败:{message}',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '启用全局智能助手功能,所有消息对话均使用智能体回答而不用使用/ai命令',
aiAgentJobInterval: '定时唤醒',
@@ -2765,6 +2822,7 @@ export default {
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
redo: '重新整理',
delete: '删除',
batchRedo: '批量重新整理',
@@ -2828,8 +2886,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
default: '默认',
host: '地址',
apiKey: 'API Key',
username: '用户名',
password: '密码',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填写后将优先使用 API Key 登录。',
category: '自动分类管理',
sequentail: '顺序下载',
force_resume: '强制继续',
@@ -3260,6 +3320,7 @@ export default {
infoDesc: '启用后可在消息会话中使用 Agent 能力,也可开启失败整理接管和智能推荐。',
providerRequired: 'LLM 提供商不能为空',
apiKeyRequired: 'LLM API 密钥不能为空',
authOrApiKeyRequired: '请填写 LLM API 密钥或先完成提供商授权',
modelRequired: 'LLM 模型名称不能为空',
maxContextTokensRequired: 'LLM 最大上下文 Token 数量必须大于 0',
recommendMaxItemsRequired: '智能推荐分析条目上限必须大于 0',

View File

@@ -994,6 +994,7 @@ export default {
aiRecommend: '智能推薦',
reRecommend: '重新生成推薦',
aiRecommendError: '智能推薦失敗',
refreshSearch: '重新搜尋',
},
browse: {
actor: '演員',
@@ -1230,6 +1231,17 @@ export default {
content: '內容',
refreshing: '正在刷新',
initializing: '正在初始化',
searchPlaceholder: '搜索日誌內容',
allLevels: '全部級別',
followTail: '跟隨最新日誌',
wrapLines: '自動換行',
pauseStream: '暫停日誌流',
resumeStream: '恢復日誌流',
waitingForLogs: '等待日誌輸出...',
paused: '已暫停',
connected: '實時更新中',
lineCount: '顯示 {visible}/{total} 行',
jumpToLatest: '查看最新 ({count})',
},
moduleTest: {
normal: '正常',
@@ -1319,13 +1331,30 @@ export default {
aiAgent: '啟用智能助手',
aiAgentEnable: '啟用智能助手',
aiAgentEnableHint: '啟用後可使用智能助手功能需要配置LLM相關參數',
aiAgentSectionTitle: '智能助手配置',
aiAgentSectionDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
llmProvider: 'LLM提供商',
llmProviderHint: '選擇使用的LLM服務提供商',
llmModel: 'LLM模型名稱',
llmModelHint: '指定使用的LLM模型gpt-3.5-turbo、deepseek-chat等',
llmModelHint: '指定使用的LLM模型deepseek-v4-flash、gpt-5.4等',
llmModelResolvedHint: '已根據模型目錄自動回填最大上下文為 {context}K來源{source}',
llmThinking: '思考模式 / 深度',
llmThinkingHint:
'思考深度off/auto/minimal/low/medium/high/max/xhigh不支援的級別會按 provider 能力自動映射到最近值',
llmThinkingLevelOff: '關閉 (off)',
llmThinkingLevelAuto: '自動 (auto)',
llmThinkingLevelMinimal: '最小 (minimal)',
llmThinkingLevelLow: '低 (low)',
llmThinkingLevelMedium: '中 (medium)',
llmThinkingLevelHigh: '高 (high)',
llmThinkingLevelMax: '極高 (max)',
llmThinkingLevelXhigh: '超高 (xhigh)',
llmSupportImageInput: '模型支援圖片輸入',
llmSupportImageInputHint:
'啟用後,消息中的圖片會按多模態圖片發送給 LLM關閉後圖片會作為附件保存到本地並將檔案路徑提供給智能助手處理',
llmSupportAudioInputOutput: '支援音頻輸入輸出',
llmSupportAudioInputOutputHint:
'啟用後,智能助手可以轉寫用戶發送的音頻消息,並在支援的渠道上回覆語音',
llmMaxContextTokens: 'LLM 最大上下文 Token 數量 (K)',
llmMaxContextTokensHint:
'設定 LLM 記錄會話歷史的最大 Token 數量上限(千),超出後將自動修整歷史記錄以節省 Token 消耗及防止超出 LLM 限制',
@@ -1334,6 +1363,34 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
llmProviderAuth: '提供商授權',
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
llmProviderConnectedAs: '目前已連接:{label}',
llmProviderDisconnect: '斷開授權',
llmProviderDisconnected: '已斷開提供商授權',
llmProviderAuthDialogTitle: '提供商授權',
llmProviderPopupBlocked: '瀏覽器攔截了授權視窗,請手動點擊下方按鈕繼續。',
llmProviderDeviceCode: '設備碼',
llmProviderOpenAuthPage: '開啟授權頁面',
llmProviderCheckAuthStatus: '檢查授權狀態',
aiVoiceApiKey: '音頻 API密鑰',
aiVoiceApiKeyHint: '音頻轉寫與語音合成使用的 API 密鑰,留空時回退到當前 LLM API 密鑰',
aiVoiceBaseUrl: '音頻基礎URL',
aiVoiceBaseUrlHint: '音頻轉寫與語音合成接口的基礎URL留空時回退到當前 LLM 基礎 URL',
aiVoiceSttModel: '音頻轉寫模型',
aiVoiceSttModelHint: '用於將音頻內容轉換為文字的模型名稱',
aiVoiceTtsModel: '語音合成模型',
aiVoiceTtsModelHint: '用於將文字內容轉換為語音的模型名稱',
aiVoiceTtsVoice: '語音音色',
aiVoiceTtsVoiceHint: '語音合成使用的發音人或音色標識',
aiVoiceLanguage: '識別語言',
aiVoiceLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
aiVoiceReplyWithText: '語音回覆附帶文字',
aiVoiceReplyWithTextHint: '發送語音回覆時,同時附帶一份文字內容',
llmTestAction: '測試調用',
llmTestSuccessToast: 'LLM 調用測試成功',
llmTestFailedToast: 'LLM 調用測試失敗',
llmTestFailedToastWithMessage: 'LLM 調用測試失敗:{message}',
aiAgentGlobal: '全局智能助手',
aiAgentGlobalHint: '啟用全局智能助手功能,所有消息對話均使用智能體回答而不用使用/ai命令',
aiAgentJobInterval: '定時喚醒',
@@ -2767,6 +2824,7 @@ export default {
actions: {
aiRedo: '智能助手整理',
aiRedoPending: '智能助手整理中...',
batchAiRedo: '智能助手批量整理',
redo: '重新整理',
delete: '刪除',
batchRedo: '批量重新整理',
@@ -2830,8 +2888,10 @@ export default {
enabled: '啟用',
default: '預設',
host: '地址',
apiKey: 'API Key',
username: '用戶名',
password: '密碼',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填寫後將優先使用 API Key 登入。',
category: '自動分類管理',
sequentail: '順序下載',
force_resume: '強制繼續',
@@ -3262,6 +3322,7 @@ export default {
infoDesc: '啟用後可在消息對話中使用 Agent 能力,也可開啟失敗整理接管與智能推薦。',
providerRequired: 'LLM 提供商不能為空',
apiKeyRequired: 'LLM API 密鑰不能為空',
authOrApiKeyRequired: '請填寫 LLM API 密鑰或先完成提供商授權',
modelRequired: 'LLM 模型名稱不能為空',
maxContextTokensRequired: 'LLM 最大上下文 Token 數量必須大於 0',
recommendMaxItemsRequired: '智能推薦分析條目上限必須大於 0',

View File

@@ -234,8 +234,8 @@ async function handlePassKeyAuth(
isConditional && conditionalAbortController
? conditionalAbortController.signal
: !isConditional && manualAbortController
? manualAbortController.signal
: undefined,
? manualAbortController.signal
: undefined,
})
await onSuccess(finishResponse)
@@ -528,7 +528,7 @@ onUnmounted(() => {
<!-- 登录表单 -->
<div v-if="!mfaDialog" class="auth-wrapper d-flex align-center justify-center">
<VCard
class="auth-card px-7 py-3 w-full h-full"
class="auth-card px-7 pt-3 w-full h-full"
:class="{ 'glass-effect': !isTransparentTheme }"
max-width="24rem"
border
@@ -602,7 +602,7 @@ onUnmounted(() => {
@click:append-inner="isPasswordVisible = !isPasswordVisible"
/>
</VCol>
<VCol cols="12">
<VCol cols="12" class="py-0">
<!-- remember me checkbox -->
<div class="d-flex align-center justify-space-between flex-wrap">
<VCheckbox v-model="form.remember" :label="t('login.stayLoggedIn')" required />
@@ -732,15 +732,15 @@ onUnmounted(() => {
&::before,
&::after {
content: '';
flex: 1;
border-block-end: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
content: '';
}
.or-divider-text {
padding-inline: 12px;
font-size: 0.8125rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
padding-inline: 12px;
white-space: nowrap;
}
}

View File

@@ -95,6 +95,9 @@ const cardScroll = useInfiniteScroll(filteredCardDataList)
// 是否刷新过
const isRefreshed = ref(false)
// 是否正在重新搜索
const isRefreshing = ref(false)
// 加载进度文本
const progressText = ref(t('common.pleaseWait'))
@@ -464,6 +467,21 @@ async function fetchData() {
}
}
// 重新搜索(使用相同参数重新触发搜索)
async function refreshSearch() {
if (isRefreshing.value || progressActive.value) return
isRefreshing.value = true
try {
// 重新搜索时退出 AI 视图,其余状态由 fetchData 内部重置
showingAiResults.value = false
await fetchData()
} catch (error) {
console.error('重新搜索失败:', error)
} finally {
isRefreshing.value = false
}
}
// 切换到智能推荐结果(自动保存筛选条件)
async function switchToAiResults() {
if (showingAiResults.value) {
@@ -808,8 +826,8 @@ onUnmounted(() => {
</div>
</VFadeTransition>
<!-- 精简标题栏 -->
<VCard v-if="isRefreshed && !progressActive" class="search-header d-flex align-center mb-3">
<!-- 精简标题栏搜索过后保持挂载加载中由按钮 :disabled / :loading 表达状态 -->
<VCard v-if="isRefreshed" class="search-header d-flex align-center mb-3">
<div class="search-info-container">
<div class="search-title text-moviepilot">
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
@@ -833,6 +851,22 @@ onUnmounted(() => {
<VSpacer />
<!-- 重新搜索按钮 -->
<VBtn
variant="text"
size="small"
icon
class="me-2 refresh-search-btn"
:loading="isRefreshing"
:disabled="isRefreshing || progressActive"
@click="refreshSearch"
>
<VIcon icon="mdi-refresh" size="20" />
<VTooltip activator="parent" location="top">
{{ t('resource.refreshSearch') }}
</VTooltip>
</VBtn>
<!-- AI操作按钮组 -->
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container me-2">
<div class="ai-toggle-buttons">
@@ -1180,6 +1214,14 @@ onUnmounted(() => {
background-color: rgba(var(--v-theme-primary), 0.05);
}
/* 重新搜索按钮 */
.refresh-search-btn {
block-size: 44px !important;
inline-size: 44px !important;
border-radius: 8px !important;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
}
/* AI按钮组样式 */
.ai-toggle-container {
position: relative;
@@ -1371,6 +1413,11 @@ onUnmounted(() => {
inline-size: 36px;
}
.refresh-search-btn {
block-size: 36px !important;
inline-size: 36px !important;
}
.ai-toggle-buttons {
block-size: 36px;
}

View File

@@ -48,6 +48,143 @@ html.v-overlay-scroll-blocked body {
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
.app-card-shell {
position: relative;
block-size: 100%;
}
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
.app-card-top-action {
z-index: 2;
}
.app-card-summary {
position: relative;
display: flex;
overflow: hidden;
align-items: stretch;
justify-content: flex-start;
block-size: 7.5rem;
min-block-size: 7.5rem;
}
.app-card-summary__content {
position: relative;
z-index: 1;
display: flex;
flex: 1 1 auto;
flex-direction: column;
justify-content: center;
min-inline-size: 0;
padding-block: 0.25rem 0.5rem;
row-gap: 0.25rem;
}
.app-card-summary__title-row {
display: flex;
align-items: flex-start;
column-gap: 0.25rem;
min-inline-size: 0;
}
.app-card-summary__title-row > .v-badge {
flex-shrink: 0;
align-self: center;
}
.app-card-summary__subtitle,
.app-card-summary__meta-item {
overflow: hidden;
min-inline-size: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-card-summary__title {
display: -webkit-box;
overflow: hidden;
flex: 0 0 auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
line-height: 1.35;
max-block-size: calc(1.35em * 2);
min-inline-size: 0;
text-overflow: ellipsis;
white-space: normal;
word-break: break-word;
}
.app-card-summary__title-row .app-card-summary__title {
flex: 1 1 auto;
}
.app-card-summary__meta {
display: flex;
overflow: hidden;
align-items: center;
column-gap: 0.5rem;
min-block-size: 1.5rem;
min-inline-size: 0;
}
.app-card-summary--single-action .app-card-summary__content {
padding-inline-end: 3.75rem;
}
.app-card-summary--double-action .app-card-summary__content {
padding-inline-end: 5rem;
}
.app-card-summary--title-subtitle {
padding-block: 0.75rem !important;
}
.app-card-summary--title-subtitle .app-card-summary__content {
justify-content: space-between;
block-size: 100%;
padding-block: 0;
}
.app-card-summary--title-subtitle .app-card-summary__title {
flex: 0 1 auto;
}
.app-card-summary--title-subtitle .app-card-summary__subtitle {
flex-shrink: 0;
}
.app-card-summary__media {
position: absolute;
z-index: 0;
display: flex;
align-items: flex-end;
justify-content: flex-end;
inset-block-end: 0.75rem;
inset-inline-end: 1rem;
pointer-events: none;
}
.app-card-summary--single-action .app-card-summary__media,
.app-card-summary--double-action .app-card-summary__media {
inset-inline-end: 1rem;
}
.app-card-summary__image {
flex-shrink: 0;
block-size: 3.5rem;
inline-size: 3.5rem;
max-block-size: 3.5rem;
max-inline-size: 3.5rem;
min-block-size: 3.5rem;
min-inline-size: 3.5rem;
}
.app-card-summary__image .v-img__img {
object-fit: contain;
}
// Toast通知样式
.Vue-Toastification__container {
z-index: 2500;

View File

@@ -59,7 +59,7 @@ const aiRedoProgressDialog = ref(false)
const aiRedoProgressActive = ref(false)
const aiRedoProgressText = ref(t('transferHistory.actions.aiRedoPending'))
const aiRedoProgressSSE = ref<any>(null)
const aiRedoProgressHistoryId = ref<number>()
const aiRedoProgressHistoryIds = ref<number[]>([])
// 重新整理IDS
const redoIds = ref<number[]>([])
@@ -374,6 +374,7 @@ async function removeSingle(deleteSrc: boolean, deleteDest: boolean) {
// 批量删除记录
async function removeBatch(deleteSrc: boolean, deleteDest: boolean) {
if (hasRunningAiRedo.value) return
// 关闭弹窗
deleteConfirmDialog.value = false
// 总条数
@@ -409,6 +410,7 @@ async function deleteConfirmHandler(deleteSrc: boolean, deleteDest: boolean) {
// 批量删除历史记录
async function removeHistoryBatch() {
if (hasRunningAiRedo.value) return
if (selected.value.length === 0) return
// 清空当前操作记录
@@ -421,6 +423,7 @@ async function removeHistoryBatch() {
}
// 批量重新整理
async function retransferBatch() {
if (hasRunningAiRedo.value) return
if (selected.value.length === 0) return
// 清空当前操作记录
@@ -462,15 +465,14 @@ function stopAiRedoProgress() {
// AI整理完成
async function finishAiRedo(success: boolean, errorMessage?: string) {
const historyId = aiRedoProgressHistoryId.value
const historyIds = [...aiRedoProgressHistoryIds.value]
const historyIdSet = new Set(historyIds)
stopAiRedoProgress()
aiRedoProgressDialog.value = false
aiRedoProgressHistoryId.value = undefined
if (historyId !== undefined) {
aiRedoIds.value = aiRedoIds.value.filter(id => id !== historyId)
}
aiRedoProgressHistoryIds.value = []
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIdSet.has(id))
selected.value = selected.value.filter(item => !historyIdSet.has(item.id))
await fetchData()
@@ -493,9 +495,14 @@ async function handleAiRedoProgressMessage(event: MessageEvent) {
// 开始监听整理进度
function startAiRedoProgress(historyId: number, progressKey: string) {
startAiRedoProgressBatch([historyId], progressKey)
}
// 开始监听批量整理进度
function startAiRedoProgressBatch(historyIds: number[], progressKey: string) {
stopAiRedoProgress()
aiRedoProgressHistoryId.value = historyId
aiRedoProgressHistoryIds.value = historyIds
aiRedoProgressDialog.value = true
aiRedoProgressActive.value = true
aiRedoProgressText.value = t('transferHistory.actions.aiRedoPending')
@@ -543,6 +550,44 @@ async function triggerAiRedo(item: TransferHistory) {
}
}
// 批量触发AI整理
async function triggerBatchAiRedo() {
if (!aiAgentEnabled.value) {
$toast.error(t('transferHistory.aiRedoDisabled'))
return
}
if (hasRunningAiRedo.value) return
const historyIds = [...new Set(selected.value.map(item => item.id))]
if (historyIds.length === 0) return
aiRedoIds.value = [...new Set([...aiRedoIds.value, ...historyIds])]
let progressStarted = false
try {
const result: { [key: string]: any } = await api.post('history/transfer/ai-redo', {
history_ids: historyIds,
})
const progressKey = result.data?.progress_key
const acceptedIds = (result.data?.history_ids as number[] | undefined) ?? historyIds
if (!result.success || !progressKey) {
$toast.error(result.message || t('transferHistory.aiRedoFailed'))
return
}
startAiRedoProgressBatch(acceptedIds, progressKey)
selected.value = selected.value.filter(item => !acceptedIds.includes(item.id))
progressStarted = true
} catch (error) {
console.error(error)
$toast.error(t('transferHistory.aiRedoFailed'))
} finally {
if (!progressStarted) {
aiRedoIds.value = aiRedoIds.value.filter(id => !historyIds.includes(id))
}
}
}
// 计算下拉菜单
function getDropdownItems(item: TransferHistory) {
return [
@@ -645,7 +690,7 @@ const historyDynamicIcon = computed(() => (selected.value.length > 0 ? 'mdi-chev
const historyDynamicMenuItems = computed(() => {
if (selected.value.length === 0) return undefined
return [
const items: Array<{ titleKey: string; icon: string; action: () => void; color?: string }> = [
{
titleKey: 'dialog.transferQueue.title',
icon: 'mdi-timer-sand-paused',
@@ -653,22 +698,36 @@ const historyDynamicMenuItems = computed(() => {
transferQueueDialog.value = true
},
},
{
titleKey: 'transferHistory.actions.batchRedo',
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
titleKey: 'transferHistory.actions.batchDelete',
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
removeHistoryBatch()
},
},
]
if (!hasRunningAiRedo.value) {
items.push(
{
titleKey: 'transferHistory.actions.batchAiRedo',
icon: 'mdi-robot-outline',
action: () => {
triggerBatchAiRedo()
},
},
{
titleKey: 'transferHistory.actions.batchRedo',
icon: 'mdi-redo-variant',
action: () => {
retransferBatch()
},
},
{
titleKey: 'transferHistory.actions.batchDelete',
icon: 'mdi-trash-can-outline',
color: 'error',
action: () => {
removeHistoryBatch()
},
},
)
}
return items
})
useDynamicButton({
@@ -980,7 +1039,7 @@ onUnmounted(() => {
<Teleport to="body" v-if="!appMode && route.path === '/history'">
<div v-if="isRefreshed" class="compact-fab-stack compact-fab-stack--history">
<VFab
v-if="selected.length > 0"
v-if="selected.length > 0 && !hasRunningAiRedo"
icon="mdi-trash-can-outline"
color="warning"
variant="tonal"
@@ -989,7 +1048,7 @@ onUnmounted(() => {
@click="removeHistoryBatch"
/>
<VFab
v-if="selected.length > 0"
v-if="selected.length > 0 && !hasRunningAiRedo"
icon="mdi-redo-variant"
color="success"
variant="tonal"
@@ -997,6 +1056,15 @@ onUnmounted(() => {
class="compact-fab compact-fab--secondary"
@click="retransferBatch"
/>
<VFab
v-if="selected.length > 0 && !hasRunningAiRedo"
icon="mdi-robot-outline"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="triggerBatchAiRedo"
/>
<VFab
icon="mdi-timer-sand-paused"
color="primary"

View File

@@ -10,9 +10,11 @@ import StorageCard from '@/components/cards/StorageCard.vue'
import ProgressDialog from '@/components/dialog/ProgressDialog.vue'
import CategoryEditDialog from '@/components/dialog/CategoryEditDialog.vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { storageAttributes } from '@/api/constants'
const { t } = useI18n()
const { global: globalTheme } = useTheme()
// 所有下载目录
const directories = ref<TransferDirectoryConf[]>([])
@@ -58,6 +60,30 @@ const SystemSettings = ref<any>({
},
})
// 编辑器主题
const editorTheme = computed(() => (globalTheme.name.value === 'light' ? 'github' : 'monokai'))
const renameEditorOptions = {
fontSize: 14,
tabSize: 2,
showLineNumbers: true,
showGutter: true,
}
const movieRenameFormat = computed({
get: () => SystemSettings.value.Basic.MOVIE_RENAME_FORMAT ?? '',
set: (value: string) => {
SystemSettings.value.Basic.MOVIE_RENAME_FORMAT = value || null
},
})
const tvRenameFormat = computed({
get: () => SystemSettings.value.Basic.TV_RENAME_FORMAT ?? '',
set: (value: string) => {
SystemSettings.value.Basic.TV_RENAME_FORMAT = value || null
},
})
// 加载系统设置
async function loadSystemSettings() {
try {
@@ -346,26 +372,48 @@ onMounted(() => {
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="SystemSettings.Basic.MOVIE_RENAME_FORMAT"
:label="t('setting.directory.movieRenameFormat')"
:hint="t('setting.directory.movieRenameFormatHint')"
persistent-hint
clearable
active
prepend-inner-icon="mdi-movie-open"
/>
<div class="rename-format-editor">
<div class="rename-format-editor__label">
<VIcon icon="mdi-movie-open" size="20" class="me-2" />
<span>{{ t('setting.directory.movieRenameFormat') }}</span>
</div>
<VAceEditor
v-model:value="movieRenameFormat"
lang="jinja2"
:theme="editorTheme"
:options="renameEditorOptions"
:print-margin="false"
:min-lines="4"
:max-lines="12"
wrap
class="rename-format-editor__ace rounded"
/>
<div class="rename-format-editor__hint">
{{ t('setting.directory.movieRenameFormatHint') }}
</div>
</div>
</VCol>
<VCol cols="12">
<VTextarea
v-model="SystemSettings.Basic.TV_RENAME_FORMAT"
:label="t('setting.directory.tvRenameFormat')"
:hint="t('setting.directory.tvRenameFormatHint')"
persistent-hint
clearable
active
prepend-inner-icon="mdi-television"
/>
<div class="rename-format-editor">
<div class="rename-format-editor__label">
<VIcon icon="mdi-television" size="20" class="me-2" />
<span>{{ t('setting.directory.tvRenameFormat') }}</span>
</div>
<VAceEditor
v-model:value="tvRenameFormat"
lang="jinja2"
:theme="editorTheme"
:options="renameEditorOptions"
:print-margin="false"
:min-lines="4"
:max-lines="12"
wrap
class="rename-format-editor__ace rounded"
/>
<div class="rename-format-editor__hint">
{{ t('setting.directory.tvRenameFormatHint') }}
</div>
</div>
</VCol>
</VRow>
</VCardText>
@@ -392,3 +440,28 @@ onMounted(() => {
@done="loadMediaCategories"
/>
</template>
<style scoped>
.rename-format-editor__label {
display: flex;
align-items: center;
color: rgba(var(--v-theme-on-surface), 0.78);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.375rem;
margin-block-end: 0.5rem;
}
.rename-format-editor__ace {
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
min-block-size: 8rem;
}
.rename-format-editor__hint {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.75rem;
line-height: 1.25rem;
margin-block-start: 0.375rem;
}
</style>

View File

@@ -480,8 +480,9 @@ onMounted(() => {
</VCardItem>
<VCardText class="py-0">
<VAceEditor
:key="`${currentTemplate}-jinja2-json`"
v-model:value="editorContent"
lang="json"
lang="jinja2_json"
:theme="editorTheme"
class="w-full h-full min-h-[30rem] rounded"
/>

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,89 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { useToast } from 'vue-toastification'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { useSetupWizard } from '@/composables/useSetupWizard'
import { useLlmProviderDirectory } from '@/composables/useLlmProviderDirectory'
const { t } = useI18n()
const $toast = useToast()
const { wizardData, validationErrors } = useSetupWizard()
const llmModels = ref<string[]>([])
const loadingModels = ref(false)
const providerRef = computed({
get: () => wizardData.value.agent.provider,
set: value => {
wizardData.value.agent.provider = value || ''
},
})
const providerItems = [
{ title: 'OpenAI', value: 'openai' },
{ title: 'Google', value: 'google' },
{ title: 'DeepSeek', value: 'deepseek' },
]
const apiKeyRef = computed({
get: () => wizardData.value.agent.apiKey,
set: value => {
wizardData.value.agent.apiKey = value || ''
},
})
const baseUrlRef = computed({
get: () => wizardData.value.agent.baseUrl,
set: value => {
wizardData.value.agent.baseUrl = value || ''
},
})
const modelRef = computed({
get: () => wizardData.value.agent.model,
set: value => {
wizardData.value.agent.model = value || ''
},
})
const maxContextTokensRef = computed({
get: () => wizardData.value.agent.maxContextTokens,
set: value => {
wizardData.value.agent.maxContextTokens = value || 0
},
})
const authConnectedRef = computed({
get: () => wizardData.value.agent.authConnected,
set: value => {
wizardData.value.agent.authConnected = Boolean(value)
},
})
const {
providerItems,
baseUrlPresetItems,
models: llmModels,
selectedProvider,
selectedModel,
loadingProviders,
loadingModels,
providerConnected,
showBaseUrlField,
showApiKeyField,
canRefreshModels,
authDialogVisible,
authPolling,
authPopupBlocked,
authSession,
handleProviderSelection,
applyModelMetadata,
loadProviders,
loadModels,
openAuthPage,
startAuth,
pollAuthSession,
disconnectAuth,
closeAuthDialog,
} = useLlmProviderDirectory({
provider: providerRef,
apiKey: apiKeyRef,
baseUrl: baseUrlRef,
model: modelRef,
maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef,
})
const jobIntervalItems = computed(() => [
{ title: t('setting.system.aiAgentJobIntervalDisabled'), value: 0 },
@@ -27,37 +96,72 @@ const jobIntervalItems = computed(() => [
{ title: t('setting.system.aiAgentJobInterval1M'), value: 720 },
])
async function loadLlmModels() {
if (!wizardData.value.agent.provider || !wizardData.value.agent.apiKey) {
return
}
const thinkingLevelItems = computed(() => [
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
{ title: t('setting.system.llmThinkingLevelMinimal'), value: 'minimal' },
{ title: t('setting.system.llmThinkingLevelLow'), value: 'low' },
{ title: t('setting.system.llmThinkingLevelMedium'), value: 'medium' },
{ title: t('setting.system.llmThinkingLevelHigh'), value: 'high' },
{ title: t('setting.system.llmThinkingLevelMax'), value: 'max' },
{ title: t('setting.system.llmThinkingLevelXhigh'), value: 'xhigh' },
])
loadingModels.value = true
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
const providerAuthLabel = computed(() => selectedProvider.value?.auth_status?.label || '')
const selectedModelInfo = computed(() => {
if (!selectedModel.value?.context_tokens_k) return ''
return t('setting.system.llmModelResolvedHint', {
context: selectedModel.value.context_tokens_k,
source: selectedModel.value.source || 'models.dev',
})
})
async function refreshModels(forceRefresh = true) {
try {
const result: { [key: string]: any } = await api.get('system/llm-models', {
params: {
provider: wizardData.value.agent.provider,
api_key: wizardData.value.agent.apiKey,
base_url: wizardData.value.agent.baseUrl,
},
})
if (result.success) {
llmModels.value = result.data || []
if (!wizardData.value.agent.model && llmModels.value.length > 0) {
wizardData.value.agent.model = llmModels.value[0]
}
}
await loadModels(forceRefresh)
} catch (error) {
$toast.error(error instanceof Error ? error.message : String(error))
console.log('Load LLM models failed:', error)
} finally {
loadingModels.value = false
}
}
onMounted(() => {
if (wizardData.value.agent.enabled && wizardData.value.agent.apiKey) {
loadLlmModels()
async function handleProviderChanged() {
handleProviderSelection(true)
if (canRefreshModels.value) {
await refreshModels(false)
}
}
function handleModelChanged() {
applyModelMetadata()
}
async function startProviderAuth(methodId: string) {
try {
await startAuth(methodId)
} catch (error) {
$toast.error(error instanceof Error ? error.message : String(error))
}
}
async function disconnectProviderAuth() {
try {
await disconnectAuth()
$toast.success(t('setting.system.llmProviderDisconnected'))
} catch (error) {
$toast.error(error instanceof Error ? error.message : String(error))
}
}
onMounted(async () => {
try {
await loadProviders()
if (wizardData.value.agent.enabled && canRefreshModels.value) {
await refreshModels(false)
}
} catch (error) {
console.log('Load LLM providers failed:', error)
}
})
</script>
@@ -89,7 +193,7 @@ onMounted(() => {
</VCol>
<template v-if="wizardData.agent.enabled">
<VCol cols="12" md="4">
<VCol cols="12" md="3">
<VSwitch
v-model="wizardData.agent.global"
:label="t('setting.system.aiAgentGlobal')"
@@ -99,7 +203,7 @@ onMounted(() => {
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" md="3">
<VSwitch
v-model="wizardData.agent.verbose"
:label="t('setting.system.aiAgentVerbose')"
@@ -109,60 +213,109 @@ onMounted(() => {
/>
</VCol>
<VCol cols="12" md="4">
<VSwitch
v-model="wizardData.agent.supportImageInput"
:label="t('setting.system.llmSupportImageInput')"
:hint="t('setting.system.llmSupportImageInputHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
<VAutocomplete
v-model="wizardData.agent.provider"
:label="t('setting.system.llmProvider')"
:hint="t('setting.system.llmProviderHint')"
:items="providerItems"
:loading="loadingProviders"
:error="validationErrors.agent.provider"
:error-messages="validationErrors.agent.provider ? [t('setupWizard.agent.providerRequired')] : []"
persistent-hint
prepend-inner-icon="mdi-robot-outline"
@update:model-value="handleProviderChanged"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.baseUrl"
<VCol v-if="showBaseUrlField" cols="12" md="6">
<VCombobox
:model-value="wizardData.agent.baseUrl"
@update:model-value="(value: any) => {
wizardData.agent.baseUrl = typeof value === 'object' && value !== null ? value.value : (value || '');
}"
:label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')"
placeholder="https://api.deepseek.com"
:placeholder="selectedProvider?.default_base_url || 'https://api.deepseek.com'"
:items="baseUrlPresetItems"
item-title="title"
item-value="value"
persistent-hint
prepend-inner-icon="mdi-link-variant"
/>
>
<template #item="{ props, item }">
<VListItem v-bind="props" :subtitle="item.raw.subtitle" />
</template>
</VCombobox>
</VCol>
<VCol cols="12" md="6">
<VCol v-if="showApiKeyField" cols="12" md="6">
<VTextField
v-model="wizardData.agent.apiKey"
:label="t('setting.system.llmApiKey')"
:hint="t('setting.system.llmApiKeyHint')"
:label="selectedProvider?.api_key_label || t('setting.system.llmApiKey')"
:hint="selectedProvider?.api_key_hint || t('setting.system.llmApiKeyHint')"
:placeholder="t('setting.system.llmApiKeyPlaceholder')"
:error="validationErrors.agent.apiKey"
:error-messages="validationErrors.agent.apiKey ? [t('setupWizard.agent.apiKeyRequired')] : []"
:error-messages="
validationErrors.agent.apiKey ? [t('setupWizard.agent.authOrApiKeyRequired')] : []
"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
/>
</VCol>
<VCol v-if="providerAuthMethods.length > 0" cols="12">
<VAlert type="info" variant="tonal">
<div class="d-flex flex-column ga-3">
<div>
<div class="text-subtitle-2">{{ t('setting.system.llmProviderAuth') }}</div>
<div class="text-body-2">
{{ selectedProvider?.description || t('setting.system.llmProviderAuthHint') }}
</div>
<div v-if="providerConnected" class="text-body-2 mt-2">
{{ t('setting.system.llmProviderConnectedAs', { label: providerAuthLabel || selectedProvider?.name }) }}
</div>
</div>
<div class="d-flex flex-wrap ga-2">
<VBtn
v-for="method in providerAuthMethods"
:key="method.id"
color="primary"
variant="tonal"
prepend-icon="mdi-account-arrow-right-outline"
@click="startProviderAuth(method.id)"
>
{{ method.label }}
</VBtn>
<VBtn
v-if="providerConnected"
color="error"
variant="text"
prepend-icon="mdi-link-off"
@click="disconnectProviderAuth"
>
{{ t('setting.system.llmProviderDisconnect') }}
</VBtn>
</div>
</div>
</VAlert>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="wizardData.agent.model"
:model-value="wizardData.agent.model"
@update:model-value="(val: any) => {
wizardData.agent.model = typeof val === 'object' && val !== null ? val.id : val;
handleModelChanged();
}"
:label="t('setting.system.llmModel')"
:hint="t('setting.system.llmModelHint')"
:items="llmModels"
item-title="name"
item-value="id"
:loading="loadingModels"
:error="validationErrors.agent.model"
:error-messages="validationErrors.agent.model ? [t('setupWizard.agent.modelRequired')] : []"
@@ -174,11 +327,15 @@ onMounted(() => {
variant="text"
icon="mdi-refresh"
size="small"
:disabled="!wizardData.agent.provider || !wizardData.agent.apiKey"
@click="loadLlmModels"
:disabled="!canRefreshModels"
@click="refreshModels(true)"
/>
</template>
</VCombobox>
<VAlert v-if="selectedModelInfo" type="info" variant="tonal" density="compact" class="mt-2">
{{ selectedModelInfo }}
</VAlert>
</VCol>
<VCol cols="12" md="6">
@@ -198,6 +355,110 @@ onMounted(() => {
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.thinkingLevel"
:label="t('setting.system.llmThinking')"
:hint="t('setting.system.llmThinkingHint')"
:items="thinkingLevelItems"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.supportImageInput"
:label="t('setting.system.llmSupportImageInput')"
:hint="t('setting.system.llmSupportImageInputHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.supportAudioInputOutput"
:label="t('setting.system.llmSupportAudioInputOutput')"
:hint="t('setting.system.llmSupportAudioInputOutputHint')"
persistent-hint
color="primary"
/>
</VCol>
<template v-if="wizardData.agent.supportAudioInputOutput">
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceApiKey"
:label="t('setting.system.aiVoiceApiKey')"
:hint="t('setting.system.aiVoiceApiKeyHint')"
persistent-hint
prepend-inner-icon="mdi-key-variant"
type="password"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceBaseUrl"
:label="t('setting.system.aiVoiceBaseUrl')"
:hint="t('setting.system.aiVoiceBaseUrlHint')"
persistent-hint
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceSttModel"
:label="t('setting.system.aiVoiceSttModel')"
:hint="t('setting.system.aiVoiceSttModelHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceTtsModel"
:label="t('setting.system.aiVoiceTtsModel')"
:hint="t('setting.system.aiVoiceTtsModelHint')"
persistent-hint
prepend-inner-icon="mdi-waveform"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceTtsVoice"
:label="t('setting.system.aiVoiceTtsVoice')"
:hint="t('setting.system.aiVoiceTtsVoiceHint')"
persistent-hint
prepend-inner-icon="mdi-account-voice"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.agent.voiceLanguage"
:label="t('setting.system.aiVoiceLanguage')"
:hint="t('setting.system.aiVoiceLanguageHint')"
persistent-hint
prepend-inner-icon="mdi-translate"
/>
</VCol>
<VCol cols="12">
<VSwitch
v-model="wizardData.agent.voiceReplyWithText"
:label="t('setting.system.aiVoiceReplyWithText')"
:hint="t('setting.system.aiVoiceReplyWithTextHint')"
persistent-hint
color="primary"
/>
</VCol>
</template>
<VCol cols="12">
<VSelect
v-model="wizardData.agent.jobInterval"
:label="t('setting.system.aiAgentJobInterval')"
@@ -259,4 +520,48 @@ onMounted(() => {
</VRow>
</VCardText>
</VCard>
<VDialog v-model="authDialogVisible" max-width="560">
<VCard>
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
<VCardText class="d-flex flex-column ga-4">
<VAlert v-if="authSession?.instructions" type="info" variant="tonal">
{{ authSession.instructions }}
</VAlert>
<VAlert v-if="authPopupBlocked" type="warning" variant="tonal">
{{ t('setting.system.llmProviderPopupBlocked') }}
</VAlert>
<div v-if="authSession?.user_code">
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
<div class="text-h5 font-weight-bold">{{ authSession.user_code }}</div>
</div>
<div v-if="authSession?.message" class="text-body-2">
{{ authSession.message }}
</div>
<div class="d-flex flex-wrap ga-2">
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="openAuthPage">
{{ t('setting.system.llmProviderOpenAuthPage') }}
</VBtn>
<VBtn
variant="tonal"
prepend-icon="mdi-refresh"
:loading="authPolling"
@click="pollAuthSession"
>
{{ t('setting.system.llmProviderCheckAuthStatus') }}
</VBtn>
</div>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="text" @click="closeAuthDialog">
{{ t('common.close') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>

View File

@@ -104,6 +104,17 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.username"
@@ -111,10 +122,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.username')"
:error="validationErrors.downloader.username"
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
required
:required="!wizardData.downloader.config.apikey"
/>
</VCol>
<VCol cols="12" md="6">
@@ -125,10 +137,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.password')"
:error="validationErrors.downloader.password"
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
required
:required="!wizardData.downloader.config.apikey"
/>
</VCol>
<VCol cols="12" md="6">

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,10 @@ const messages = ref<Message[]>([])
// 当前页数据
const currData = ref<Message[]>([])
// 已加载消息的签名集合
// 使用消息内容签名去重,避免仅按秒级时间戳判断时误吞同一秒内的不同消息。
const messageKeys = new Set<string>()
// 是否完成加载
const isLoaded = ref(false)
@@ -29,18 +33,72 @@ const page = ref(1)
// 存量消息最新时间
const lastTime = ref('')
// 获取消息时间
function getMessageTime(message: Message) {
return message.reg_time || message.date || ''
}
// 生成消息签名
function getMessageKey(message: Message) {
return [
message.action ?? '',
message.userid ?? '',
message.reg_time ?? '',
message.date ?? '',
message.title ?? '',
message.text ?? '',
message.image ?? '',
message.link ?? '',
message.note ?? '',
].join('::')
}
// 排序消息列表,确保最新消息始终位于底部
function sortMessages(items: Message[]) {
return [...items].sort((a, b) => compareTime(getMessageTime(a), getMessageTime(b)))
}
// 记录最新消息时间
function updateLastTime(message: Message) {
const messageTime = getMessageTime(message)
if (messageTime && compareTime(messageTime, lastTime.value) > 0) {
lastTime.value = messageTime
}
}
// 合并消息到当前列表
function mergeMessages(items: Message[]) {
let hasNewMessage = false
for (const item of sortMessages(items)) {
const messageKey = getMessageKey(item)
if (messageKeys.has(messageKey)) {
continue
}
messageKeys.add(messageKey)
messages.value.push(item)
updateLastTime(item)
hasNewMessage = true
}
if (hasNewMessage) {
messages.value = sortMessages(messages.value)
}
return hasNewMessage
}
// SSE消息处理函数
function handleSSEMessage(event: MessageEvent) {
const message = event.data
if (message) {
const object = JSON.parse(message)
// 使用reg_time或date字段进行比较
const messageTime = object.reg_time || object.date
if (compareTime(messageTime, lastTime.value) <= 0) return
messages.value.push(object)
nextTick(() => {
emit('scroll') // 新消息到达时触发智能滚动
})
if (mergeMessages([object])) {
nextTick(() => {
emit('scroll') // 新消息到达时触发智能滚动
})
}
}
}
@@ -75,28 +133,10 @@ async function loadMessages({ done }: { done: any }) {
// 已加载过
isLoaded.value = true
if (currData.value.length > 0) {
// 按时间排序,确保最新的消息在最后
currData.value.sort((a, b) => {
const timeA = a.reg_time || a.date || ''
const timeB = b.reg_time || b.date || ''
return compareTime(timeA, timeB)
})
// 取最后一条时间为存量消息最新时间
const lastMessage = currData.value[currData.value.length - 1]
lastTime.value = lastMessage.reg_time || lastMessage.date || ''
// 合并数据并重新排序
const allMessages = [...currData.value, ...messages.value]
allMessages.sort((a, b) => {
const timeA = a.reg_time || a.date || ''
const timeB = b.reg_time || b.date || ''
return compareTime(timeA, timeB)
})
messages.value = allMessages
const hasNewMessage = mergeMessages(currData.value)
// 首次加载时滚动到底部
if (page.value === 1) {
if (page.value === 1 && hasNewMessage) {
nextTick(() => {
emit('scroll')
})
@@ -118,6 +158,26 @@ async function loadMessages({ done }: { done: any }) {
}
}
// 主动刷新最新一页消息作为SSE偶发丢流时的兜底
async function refreshLatestMessages() {
try {
const latestMessages = (await api.get('message/web', {
params: {
page: 1,
size: 20,
},
})) as Message[]
if (mergeMessages(latestMessages)) {
nextTick(() => {
emit('scroll')
})
}
} catch (error) {
console.error('刷新最新消息失败:', error)
}
}
// 比较yyyy-MM-dd HH:mm:ss时间大小
function compareTime(time1: string, time2: string) {
if (!time1 && !time2) return 0
@@ -160,14 +220,19 @@ function pauseSSE() {
// 恢复SSE连接
function resumeSSE() {
if (manager) {
// 先移除再重建监听确保恢复时拿到一条新的SSE连接。
manager.removeMessageListener('message-view')
manager.addMessageListener('message-view', handleSSEMessage)
}
refreshLatestMessages()
}
// 暴露方法给父组件
defineExpose({
pauseSSE,
resumeSSE,
refreshLatestMessages,
})
onMounted(() => {
@@ -194,7 +259,7 @@ onMounted(() => {
<div>
<div
v-for="(msg, index) in messages"
:key="index"
:key="getMessageKey(msg) || index"
class="chat-group d-flex mt-5 mb-8"
:class="msg.action == 1 ? 'flex-row align-start' : 'flex-row-reverse align-end'"
>