Compare commits

...

7 Commits

Author SHA1 Message Date
jxxghp
5c4511f935 Deduplicate offline connection prompts by status 2026-07-10 07:40:25 +08:00
jxxghp
212ba94e11 chore: update version to 2.14.3 2026-07-09 19:34:35 +08:00
jxxghp
f863a09212 Refine dashboard layout drafts and episode status badges 2026-07-09 19:33:27 +08:00
jxxghp
728ced475b feat: add computed dialog subtitle for download confirmation 2026-07-08 17:19:47 +08:00
InfinityPacer
a192e00db5 ci(pr-agent): simplify review workflow (#518) 2026-07-08 12:51:49 +08:00
jxxghp
85e6b17db7 feat: add agent MCP settings 2026-07-08 08:44:33 +08:00
InfinityPacer
156d835d6d ci(pr-agent): align inline review workflow (#517) 2026-07-08 07:05:24 +08:00
11 changed files with 1259 additions and 81 deletions

View File

@@ -11,7 +11,7 @@ on:
- review_requested
- synchronize
issue_comment:
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
# issue_comment 同时覆盖普通 issue因此 job 里还会再判断是否属于 PR。
types:
- created
@@ -27,8 +27,7 @@ permissions:
jobs:
pr-agent:
name: PR-Agent review and describe
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
name: PR-Agent inline review
if: >-
github.event.sender.type != 'Bot' &&
(
@@ -38,8 +37,6 @@ jobs:
github.event.issue.pull_request != null &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
(
github.event.comment.body == '/review' ||
startsWith(github.event.comment.body, '/review ') ||
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ') ||
github.event.comment.body == '/improve' ||
@@ -49,13 +46,180 @@ jobs:
)
)
)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Detect PR review language
id: pr_language
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
pr_info="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
python3 - "${pr_info}" >> "${GITHUB_OUTPUT}" <<'PY'
import json
import re
import sys
from pathlib import Path
pr = json.loads(Path(sys.argv[1]).read_text())
title = pr.get("title") or ""
body = pr.get("body") or ""
labels = {item.get("name", "") for item in pr.get("labels") or []}
skip_pr_agent = "true" if "skip pr-agent" in labels or re.search(r"^(?:\[Auto\]|Auto)", title) else "false"
head_sha = pr.get("head", {}).get("sha") or ""
body = re.sub(
r"\n*##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*"
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.IGNORECASE | re.DOTALL,
)
body = re.sub(
r"<!-- pr-agent-summary:start -->.*?<!-- pr-agent-summary:end -->",
" ",
body,
flags=re.DOTALL,
)
body = re.sub(r"```.*?```", " ", body, flags=re.DOTALL)
text = f"{title}\n{body}"
cjk_count = len(re.findall(r"[\u4e00-\u9fff]", text))
latin_words = len(re.findall(r"\b[A-Za-z][A-Za-z]{2,}\b", text))
if cjk_count >= 4:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
summary_language = "中文"
elif latin_words >= 8:
response_language = "en-US"
summary_heading = "PR-Agent Summary"
summary_language = "English"
else:
response_language = "zh-CN"
summary_heading = "PR-Agent 摘要"
summary_language = "中文"
print(f"response_language={response_language}")
print(f"summary_heading={summary_heading}")
print(f"summary_language={summary_language}")
print(f"skip_pr_agent={skip_pr_agent}")
print(f"head_sha={head_sha}")
PY
- name: Prepare PR-Agent description markers
id: prepare_description
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/describe' ||
startsWith(github.event.comment.body, '/describe ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
SUMMARY_HEADING: ${{ steps.pr_language.outputs.summary_heading }}
run: |
set -euo pipefail
current_body="$(mktemp)"
next_body="$(mktemp)"
payload="$(mktemp)"
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
cp "${current_body}" "${body_backup}"
python3 - "${current_body}" "${next_body}" <<'PY'
import os
import re
import sys
from pathlib import Path
current_path = Path(sys.argv[1])
next_path = Path(sys.argv[2])
body = current_path.read_text()
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
placeholder = "pr_agent:summary"
summary_heading = os.environ.get("SUMMARY_HEADING") or "PR-Agent 摘要"
agent_block = f"## {summary_heading}\n\n{start}\n{placeholder}\n{end}\n"
body = re.sub(
r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*(?=<!-- pr-agent-summary:start -->)",
f"## {summary_heading}\n\n",
body,
)
start_index = body.find(start)
end_index = body.find(end)
if start_index >= 0 and end_index > start_index:
next_body = body[: start_index + len(start)] + f"\n{placeholder}\n" + body[end_index:]
else:
separator = "\n\n" if body.strip() else ""
next_body = body.rstrip() + separator + agent_block
next_path.write_text(next_body)
PY
cp "${next_body}" "${placeholder_body}"
body_changed=false
if ! cmp -s "${current_body}" "${next_body}"; then
python3 - "${next_body}" "${payload}" <<'PY'
import json
import sys
from pathlib import Path
body = Path(sys.argv[1]).read_text()
Path(sys.argv[2]).write_text(json.dumps({"body": body}, ensure_ascii=False))
PY
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
body_changed=true
fi
echo "body_changed=${body_changed}" >> "${GITHUB_OUTPUT}"
- name: Snapshot PR-Agent inline comments
id: inline_state_before
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
inline_ids="$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | select(.user.login == "github-actions[bot]") | .id' | jq -sc '.')"
inline_ids_b64="$(printf '%s' "${inline_ids}" | base64 -w0)"
echo "inline_ids_b64=${inline_ids_b64}" >> "${GITHUB_OUTPUT}"
- name: Run PR-Agent
id: pragent
if: steps.pr_language.outputs.skip_pr_agent != 'true'
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
uses: docker://pragent/pr-agent:0.37.0-github_action@sha256:4ec7bac814050a1bc8c96ab2fab6b7b0f65df0049a5ec43f3fee1a0b551c28ca
uses: docker://pragent/pr-agent:0.39.0-github_action@sha256:b253845caa8c7ff5ce8be78f32996647982bdd4890826a962b78eff2e385a825
env:
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
GITHUB_TOKEN: ${{ github.token }}
@@ -72,52 +236,348 @@ jobs:
config.fallback_models: '["gpt-5.4"]'
config.reasoning_effort: "xhigh"
config.ai_timeout: "900"
config.response_language: "zh-CN"
config.response_language: ${{ steps.pr_language.outputs.response_language }}
config.large_patch_policy: "clip"
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
config.ignore_pr_labels: '["skip pr-agent"]'
# pull_request_target 事件默认自动执行 /review 和 /describe/improve 保持手动触发
github_action_config.auto_review: "true"
# PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议
github_action_config.auto_review: "false"
github_action_config.auto_describe: "true"
github_action_config.auto_improve: "false"
github_action_config.auto_improve: "true"
# 允许触发自动工具的 PR 动作。包含 synchronize便于新 commit 推送后刷新结果
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
# synchronize 由 push_commands 单独处理;每次 push 更新摘要和行内 Review旧行评由 GitHub 标记 outdated
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested"]'
github_action_config.handle_push_trigger: "true"
github_action_config.push_commands: '["/describe", "/improve"]'
# 保留 action outputs便于后续 workflow 编排或排查。
github_action_config.enable_output: "true"
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略
# /describe 行为控制;只更新 PR body 中的 PR-Agent 摘要占位符
pr_description.generate_ai_title: "false"
pr_description.publish_labels: "false"
pr_description.publish_description_as_comment: "false"
pr_description.publish_description_as_comment_persistent: "false"
pr_description.enable_pr_diagram: "false"
pr_description.enable_pr_type: "false"
pr_description.enable_help_text: "false"
pr_description.enable_help_comment: "false"
pr_description.enable_semantic_files_types: "false"
pr_description.collapsible_file_list: "adaptive"
pr_description.add_original_user_description: "true"
pr_description.use_description_markers: "true"
pr_description.final_update_message: "false"
pr_description.extra_instructions: |
Match the configured response language.
Generate a moderately detailed PR summary covering the change goal, key implementation details, configuration or compatibility impact, tests, and notable risks.
Use 2-4 bullets for small PRs; use 4-8 bullets for feature or multi-file PRs.
Avoid low-value file lists and local command transcripts.
# /review 输出策略,聚焦维护者需要处理的风险和缺口
pr_reviewer.extra_instructions: |
请用中文输出。
优先指出 P0/P1 风险,避免纠结纯格式问题。
重点检查安全、权限、状态一致性、异步/缓存、副作用和测试缺口。
pr_reviewer.num_max_findings: "5"
pr_reviewer.persistent_comment: "true"
pr_reviewer.publish_output_no_suggestions: "true"
pr_reviewer.require_tests_review: "true"
pr_reviewer.require_security_review: "true"
pr_reviewer.require_estimate_effort_to_review: "true"
pr_reviewer.require_can_be_split_review: "true"
pr_reviewer.require_todo_scan: "false"
pr_reviewer.enable_review_labels_effort: "false"
pr_reviewer.enable_review_labels_security: "true"
# /improve 和 /ask 的手动命令策略。
# /improve 以 GitHub 内联建议呈现,便于像正式 review discussion 一样逐条处理
pr_code_suggestions.extra_instructions: |
Match the configured response language.
Only provide substantive issues that maintainers should address; avoid style-only, preference-only, or low-value suggestions.
For prioritized issues, start the suggestion body with one of these Markdown prefixes: 🔴 **High Risk**:, 🟡 **Medium Risk**:, or 🔵 **Low Risk**:.
pr_code_suggestions.focus_only_on_problems: "true"
pr_code_suggestions.suggestions_score_threshold: "7"
pr_code_suggestions.commitable_code_suggestions: "false"
pr_code_suggestions.suggestions_score_threshold: "3"
pr_code_suggestions.num_code_suggestions_per_chunk: "3"
pr_code_suggestions.commitable_code_suggestions: "true"
pr_code_suggestions.publish_output_no_suggestions: "false"
pr_questions.use_conversation_history: "true"
# 可选成本和噪音控制:
# github_action_config.auto_improve: "true"
# config.verbosity_level: "1"
# pr_reviewer.num_max_findings: "3"
- name: Publish PR-Agent code review summary
if: >-
steps.pr_language.outputs.skip_pr_agent != 'true' &&
(
github.event_name == 'pull_request_target' ||
(
github.event_name == 'issue_comment' &&
(
github.event.comment.body == '/improve' ||
startsWith(github.event.comment.body, '/improve ')
)
)
)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
BEFORE_INLINE_IDS_B64: ${{ steps.inline_state_before.outputs.inline_ids_b64 }}
RUN_HEAD_SHA: ${{ steps.pr_language.outputs.head_sha }}
SUMMARY_LANGUAGE: ${{ steps.pr_language.outputs.summary_language }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENAI_API_BASE: ${{ secrets.OPENAI_API_BASE }}
SUMMARY_MODEL: gpt-5.5
run: |
set -euo pipefail
before_ids="$(printf '%s' "${BEFORE_INLINE_IDS_B64:-W10=}" | base64 -d)"
pr_info="$(mktemp)"
comments="$(mktemp)"
review_data="$(mktemp)"
payload="$(mktemp)"
gh api "repos/${REPO}/pulls/${PR_NUMBER}" > "${pr_info}"
CURRENT_HEAD_SHA="$(jq -r '.head.sha' "${pr_info}")"
HEAD_SHA="${RUN_HEAD_SHA:-${CURRENT_HEAD_SHA}}"
if [ "${CURRENT_HEAD_SHA}" != "${HEAD_SHA}" ]; then
echo "PR head changed from ${HEAD_SHA} to ${CURRENT_HEAD_SHA}; skip stale code review summary."
exit 0
fi
short_sha="${HEAD_SHA:0:7}"
pr_url="https://github.com/${REPO}/pull/${PR_NUMBER}"
commit_url="https://github.com/${REPO}/commit/${HEAD_SHA}"
gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/comments?per_page=100" --jq '.[] | @json' > "${comments}"
python3 - "${before_ids}" "${comments}" "${review_data}" "${HEAD_SHA}" "${pr_url}" "${commit_url}" "${short_sha}" <<'PY'
import json
import sys
from pathlib import Path
before_ids = set(json.loads(sys.argv[1] or "[]"))
comments_path = Path(sys.argv[2])
output_path = Path(sys.argv[3])
head_sha = sys.argv[4]
pr_url = sys.argv[5]
commit_url = sys.argv[6]
short_sha = sys.argv[7]
comments = []
for line in comments_path.read_text().splitlines():
if line.strip():
comments.append(json.loads(line))
new_comments = [
item for item in comments
if item.get("user", {}).get("login") == "github-actions[bot]"
and item.get("id") not in before_ids
and item.get("commit_id") == head_sha
]
suggestions = []
for item in new_comments:
body = (item.get("body") or "").strip()
first_line = next((line.strip() for line in body.splitlines() if line.strip()), "")
suggestions.append({
"path": item.get("path"),
"line": item.get("line") or item.get("start_line"),
"url": item.get("html_url"),
"summary": first_line[:500],
"body": body[:1500],
})
output_path.write_text(json.dumps({
"pr_url": pr_url,
"commit_url": commit_url,
"short_sha": short_sha,
"suggestions": suggestions,
}, ensure_ascii=False))
PY
python3 - "${review_data}" "${payload}" <<'PY'
import json
import os
import textwrap
import urllib.error
import urllib.request
from pathlib import Path
review_data = json.loads(Path(os.sys.argv[1]).read_text())
payload_path = Path(os.sys.argv[2])
suggestions = review_data["suggestions"]
marker = "<!-- pr-agent-code-review-summary -->"
summary_language = os.environ.get("SUMMARY_LANGUAGE") or "中文"
use_chinese = summary_language != "English"
def fallback_summary() -> str:
if not suggestions:
if use_chinese:
return "已审查本次变更,未发现需要进一步反馈或调整的问题。"
return "Reviewed this change and found no further feedback or required adjustments."
if use_chinese:
lines = [f"本轮代码审查新增 {len(suggestions)} 条行内建议,建议优先查看以下位置:"]
else:
noun = "suggestion" if len(suggestions) == 1 else "suggestions"
lines = [f"This review added {len(suggestions)} inline {noun}. Consider reviewing these locations first:"]
for item in suggestions[:5]:
location = f"{item.get('path')}:{item.get('line')}" if item.get("line") else str(item.get("path"))
summary = item.get("summary") or "查看行内建议"
url = item.get("url")
if use_chinese:
lines.append(f"- [{location}]({url}){summary}" if url else f"- {location}{summary}")
else:
lines.append(f"- [{location}]({url}): {summary}" if url else f"- {location}: {summary}")
if len(suggestions) > 5:
if use_chinese:
lines.append(f"- 其余 {len(suggestions) - 5} 条请在 Files changed 的行内评论中查看。")
else:
lines.append(f"- Review the remaining {len(suggestions) - 5} inline comments in Files changed.")
return "\n".join(lines)
def llm_summary() -> str | None:
if not suggestions:
return None
api_base = (os.environ.get("OPENAI_API_BASE") or "").rstrip("/")
api_key = os.environ.get("OPENAI_KEY") or ""
model = os.environ.get("SUMMARY_MODEL") or "gpt-5.5"
if not api_base or not api_key:
return None
if use_chinese:
task = (
"你是独立的代码审查摘要助手。只基于本轮已经发布的行内审查意见做简短汇总,"
"不新增审查结论,不替维护者判断 PR 是否可以合并。请用中文 Markdown 输出:"
"第一段说明本轮审查已完成,并已在行内留下需要关注的建议;如有建议,"
"概括 1-3 个主要关注点,使用“建议关注”“可能影响”“可优先查看”等中立表述。"
"不要输出表格,不要复述所有文件,不要使用“可以合并”“不建议合并”“阻塞合并”"
"“先处理后再合入”等合并裁决措辞。"
)
system_prompt = "你是严谨、中立的代码审查摘要助手。输出中文 Markdown简洁自然。"
else:
task = (
"You are an independent code review summarizer. Summarize only the inline review comments "
"already posted in this run; do not add new review conclusions or decide whether the PR should merge. "
"Write concise Markdown in English. Start by noting that review completed and inline suggestions "
"were left; then summarize 1-3 main points using neutral phrasing such as \"consider\", "
"\"may affect\", or \"worth reviewing\". Do not output tables, list every file, or use merge-gate "
"wording such as \"ready to merge\", \"do not merge\", \"blocks merging\", or \"must be fixed before merge\"."
)
system_prompt = "You are a rigorous, neutral code review summarizer. Write concise English Markdown."
user_content = {
"task": task,
"suggestions": suggestions[:10],
}
request_body = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": json.dumps(user_content, ensure_ascii=False)},
],
"temperature": 0.2,
}
request = urllib.request.Request(
f"{api_base}/chat/completions",
data=json.dumps(request_body).encode("utf-8"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=60) as response:
data = json.loads(response.read().decode("utf-8"))
content = data["choices"][0]["message"]["content"].strip()
return content or None
except (KeyError, TimeoutError, urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError):
return None
summary = llm_summary() or fallback_summary()
commit_label = "审查提交:" if use_chinese else "Reviewed commit:"
body = textwrap.dedent(f"""\
{marker}
## Code Review
{summary}
{commit_label} [{review_data["short_sha"]}]({review_data["commit_url"]})
""")
payload_path.write_text(json.dumps({"body": body}, ensure_ascii=False))
PY
new_comment_id="$(gh api --method POST "repos/${REPO}/issues/${PR_NUMBER}/comments" --input "${payload}" --jq '.id')"
comment_ids="$(gh api --paginate "repos/${REPO}/issues/${PR_NUMBER}/comments?per_page=100" --jq ".[] | select(.id != ${new_comment_id} and .user.login == \"github-actions[bot]\" and ((.body | startswith(\"<!-- pr-agent-code-review-summary -->\")) or (.body | startswith(\"<!-- pr-agent-update-notification -->\")))) | .id")"
if [ -z "${comment_ids}" ]; then
echo "No previous PR-Agent code review summary to clean."
exit 0
fi
while IFS= read -r comment_id; do
[ -z "${comment_id}" ] && continue
gh api --method DELETE "repos/${REPO}/issues/comments/${comment_id}" >/dev/null
done <<< "${comment_ids}"
- name: Restore PR-Agent description markers on failure
if: >-
failure() &&
steps.prepare_description.outputs.body_changed == 'true' &&
steps.pr_language.outputs.skip_pr_agent != 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
run: |
set -euo pipefail
body_backup="${RUNNER_TEMP}/pr-agent-body-before-describe.md"
placeholder_body="${RUNNER_TEMP}/pr-agent-body-with-placeholder.md"
current_body="$(mktemp)"
payload="$(mktemp)"
if [ ! -s "${body_backup}" ] || [ ! -s "${placeholder_body}" ]; then
echo "No PR body backup found."
exit 0
fi
gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.body // ""' > "${current_body}"
python3 - "${body_backup}" "${placeholder_body}" "${current_body}" "${payload}" <<'PY'
import json
import re
import sys
from pathlib import Path
backup_body = Path(sys.argv[1]).read_text()
placeholder_body = Path(sys.argv[2]).read_text()
current_body = Path(sys.argv[3]).read_text()
payload_path = Path(sys.argv[4])
start = "<!-- pr-agent-summary:start -->"
end = "<!-- pr-agent-summary:end -->"
heading_re = re.compile(r"(?im)^##\s+(PR-Agent\s+摘要|PR-Agent Summary)\s*\n\s*")
def find_block(body: str) -> tuple[int, int] | None:
start_index = body.find(start)
end_index = body.find(end)
if start_index < 0 or end_index <= start_index:
return None
end_index += len(end)
heading_start = start_index
prefix = body[:start_index]
matches = list(heading_re.finditer(prefix))
if matches:
last = matches[-1]
if prefix[last.end():].strip() == "":
heading_start = last.start()
return heading_start, end_index
current_block = find_block(current_body)
placeholder_block = find_block(placeholder_body)
backup_block = find_block(backup_body)
if not current_block or not placeholder_block:
print("No PR-Agent summary block to restore.")
raise SystemExit(0)
current_section = current_body[current_block[0]:current_block[1]]
placeholder_section = placeholder_body[placeholder_block[0]:placeholder_block[1]]
if "pr_agent:summary" not in current_section:
print("No visible PR-Agent summary placeholder to restore.")
raise SystemExit(0)
if current_section != placeholder_section:
print("Current PR-Agent summary block changed; skip restore.")
raise SystemExit(0)
restored_section = backup_body[backup_block[0]:backup_block[1]] if backup_block else ""
next_body = current_body[:current_block[0]] + restored_section + current_body[current_block[1]:]
next_body = re.sub(r"\n{4,}", "\n\n\n", next_body).rstrip() + "\n"
payload_path.write_text(json.dumps({"body": next_body}, ensure_ascii=False))
PY
if [ -s "${payload}" ]; then
gh api --method PATCH "repos/${REPO}/pulls/${PR_NUMBER}" --input "${payload}" >/dev/null
fi

View File

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

View File

@@ -68,6 +68,14 @@ const buttonText = computed(() =>
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
)
// 下载确认副标题,未传媒体标题时回退到种子标题。
const dialogSubtitle = computed(() => {
const siteName = props.torrent?.site_name?.trim()
const displayTitle = props.title?.trim() || props.torrent?.title?.trim()
return [siteName, displayTitle].filter(Boolean).join(' - ')
})
// 加载目录设置
async function loadDirectories() {
try {
@@ -78,6 +86,7 @@ async function loadDirectories() {
}
}
// 将下载目录配置转换为下载器可识别的存储路径。
function convertToUri(item: TransferDirectoryConf) {
if (!item.download_path) {
return undefined
@@ -181,7 +190,7 @@ onMounted(() => {
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
</template>
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
<VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="emit('close')" />
<VDivider />

View File

@@ -0,0 +1,449 @@
<script lang="ts" setup>
import { useToast } from 'vue-toastification'
import api from '@/api'
import { useDisplay } from 'vuetify'
import { useI18n } from 'vue-i18n'
type AgentMcpTransport = 'stdio' | 'sse' | 'http' | 'streamable_http'
interface AgentMcpServer {
id: string
name: string
enabled: boolean
transport: AgentMcpTransport
description?: string | null
command?: string | null
args: string[]
env: Record<string, string>
url?: string | null
headers: Record<string, string>
timeout: number
tool_prefix?: string | null
require_admin: boolean
}
interface EditableAgentMcpServer extends AgentMcpServer {
argsText: string
envText: string
headersText: string
}
interface AgentMcpToolInfo {
name: string
agent_tool_name: string
description?: string
}
interface AgentMcpTestState {
loading: boolean
success?: boolean
message?: string
tools?: AgentMcpToolInfo[]
}
const props = defineProps<{
modelValue: boolean
servers: AgentMcpServer[]
}>()
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'saved', value: AgentMcpServer[]): void
}>()
const { t } = useI18n()
const toast = useToast()
const display = useDisplay()
const saving = ref(false)
const localServers = ref<EditableAgentMcpServer[]>([])
const testStates = ref<Record<string, AgentMcpTestState>>({})
const dialogVisible = computed({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
})
const transportItems = computed(() => [
{ title: t('setting.system.aiAgentMcpTransportStdio'), value: 'stdio' },
{ title: t('setting.system.aiAgentMcpTransportSse'), value: 'sse' },
{ title: t('setting.system.aiAgentMcpTransportHttp'), value: 'http' },
])
function createServerId() {
return `mcp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function dictToLines(value?: Record<string, string>) {
return Object.entries(value || {})
.map(([key, item]) => `${key}=${item}`)
.join('\n')
}
function linesToDict(value: string) {
return String(value || '')
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.reduce<Record<string, string>>((result, line) => {
const separatorIndex = line.indexOf('=')
const key = separatorIndex >= 0 ? line.slice(0, separatorIndex).trim() : line
if (!key) return result
result[key] = separatorIndex >= 0 ? line.slice(separatorIndex + 1).trim() : ''
return result
}, {})
}
function toEditableServer(server: AgentMcpServer): EditableAgentMcpServer {
return {
id: server.id || createServerId(),
name: server.name || t('setting.system.aiAgentMcpUnnamedServer'),
enabled: server.enabled !== false,
transport: server.transport || 'stdio',
description: server.description || '',
command: server.command || '',
args: [...(server.args || [])],
env: { ...(server.env || {}) },
url: server.url || '',
headers: { ...(server.headers || {}) },
timeout: Number(server.timeout || 30),
tool_prefix: server.tool_prefix || '',
require_admin: server.require_admin !== false,
argsText: (server.args || []).join('\n'),
envText: dictToLines(server.env),
headersText: dictToLines(server.headers),
}
}
function toServerPayload(server: EditableAgentMcpServer): AgentMcpServer {
return {
id: server.id || createServerId(),
name: String(server.name || '').trim() || t('setting.system.aiAgentMcpUnnamedServer'),
enabled: Boolean(server.enabled),
transport: server.transport || 'stdio',
description: String(server.description || '').trim() || null,
command: String(server.command || '').trim() || null,
args: String(server.argsText || '')
.split('\n')
.map(item => item.trim())
.filter(Boolean),
env: linesToDict(server.envText),
url: String(server.url || '').trim() || null,
headers: linesToDict(server.headersText),
timeout: Math.max(1, Number(server.timeout || 30)),
tool_prefix: String(server.tool_prefix || '').trim() || null,
require_admin: Boolean(server.require_admin),
}
}
function resetLocalServers() {
localServers.value = (props.servers || []).map(toEditableServer)
testStates.value = {}
}
function addServer() {
localServers.value.push(
toEditableServer({
id: createServerId(),
name: t('setting.system.aiAgentMcpNewServer'),
enabled: true,
transport: 'stdio',
description: '',
command: '',
args: [],
env: {},
url: '',
headers: {},
timeout: 30,
tool_prefix: '',
require_admin: true,
}),
)
}
function removeServer(server: EditableAgentMcpServer) {
localServers.value = localServers.value.filter(item => item.id !== server.id)
}
async function testServer(server: EditableAgentMcpServer) {
const payload = toServerPayload(server)
testStates.value[payload.id] = { loading: true }
try {
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers/test', { server: payload })
testStates.value[payload.id] = {
loading: false,
success: Boolean(result.success),
message: result.message || result.data?.message || '',
tools: result.data?.tools || [],
}
} catch (error) {
testStates.value[payload.id] = {
loading: false,
success: false,
message: error instanceof Error ? error.message : String(error),
tools: [],
}
}
}
async function saveServers() {
saving.value = true
try {
const servers = localServers.value.map(toServerPayload)
const result: { [key: string]: any } = await api.post('message/agent/mcp/servers', { servers })
if (result.success) {
toast.success(t('setting.system.aiAgentMcpSaveSuccess'))
emit('saved', servers)
dialogVisible.value = false
return
}
toast.error(result.message || t('setting.system.aiAgentMcpSaveFailed'))
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error))
} finally {
saving.value = false
}
}
watch(
() => props.modelValue,
value => {
if (value) resetLocalServers()
},
)
watch(
() => props.servers,
() => {
if (props.modelValue) resetLocalServers()
},
{ deep: true },
)
</script>
<template>
<VDialog v-model="dialogVisible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<template #prepend>
<VIcon icon="mdi-server-network" class="me-2" />
</template>
<VCardTitle>{{ t('setting.system.aiAgentMcpDialogTitle') }}</VCardTitle>
<VCardSubtitle>{{ t('setting.system.aiAgentMcpDialogDesc') }}</VCardSubtitle>
</VCardItem>
<VDialogCloseBtn @click="dialogVisible = false" />
<VCardText>
<div class="d-flex justify-end mb-4">
<VBtn color="success" variant="tonal" prepend-icon="mdi-plus" @click="addServer">
{{ t('setting.system.aiAgentMcpAddServer') }}
</VBtn>
</div>
<VAlert v-if="localServers.length === 0" type="info" variant="tonal">
{{ t('setting.system.aiAgentMcpEmpty') }}
</VAlert>
<VExpansionPanels v-else variant="accordion" class="agent-mcp-panels">
<VExpansionPanel v-for="server in localServers" :key="server.id">
<VExpansionPanelTitle>
<div class="agent-mcp-panel-title">
<VIcon :icon="server.enabled ? 'mdi-lan-connect' : 'mdi-lan-disconnect'" />
<span>{{ server.name || t('setting.system.aiAgentMcpUnnamedServer') }}</span>
<VChip size="small" variant="tonal">{{ server.transport }}</VChip>
</div>
</VExpansionPanelTitle>
<VExpansionPanelText>
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="server.name"
:label="t('setting.system.aiAgentMcpName')"
prepend-inner-icon="mdi-label-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="server.transport"
:label="t('setting.system.aiAgentMcpTransport')"
:items="transportItems"
prepend-inner-icon="mdi-transit-connection-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="server.enabled"
:label="t('setting.system.aiAgentMcpEnabled')"
:hint="t('setting.system.aiAgentMcpEnabledHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="server.require_admin"
:label="t('setting.system.aiAgentMcpRequireAdmin')"
:hint="t('setting.system.aiAgentMcpRequireAdminHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="server.timeout"
:label="t('setting.system.aiAgentMcpTimeout')"
type="number"
min="1"
prepend-inner-icon="mdi-timer-sand"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="server.tool_prefix"
:label="t('setting.system.aiAgentMcpToolPrefix')"
:hint="t('setting.system.aiAgentMcpToolPrefixHint')"
persistent-hint
prepend-inner-icon="mdi-form-textbox"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.description"
:label="t('setting.system.aiAgentMcpDescription')"
rows="1"
auto-grow
prepend-inner-icon="mdi-text-box-outline"
/>
</VCol>
<template v-if="server.transport === 'stdio'">
<VCol cols="12" md="6">
<VTextField
v-model="server.command"
:label="t('setting.system.aiAgentMcpCommand')"
placeholder="npx"
prepend-inner-icon="mdi-console"
/>
</VCol>
<VCol cols="12" md="6">
<VTextarea
v-model="server.argsText"
:label="t('setting.system.aiAgentMcpArgs')"
:hint="t('setting.system.aiAgentMcpArgsHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-format-list-bulleted"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.envText"
:label="t('setting.system.aiAgentMcpEnv')"
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-code-braces"
/>
</VCol>
</template>
<template v-else>
<VCol cols="12">
<VTextField
v-model="server.url"
:label="t('setting.system.aiAgentMcpUrl')"
placeholder="http://127.0.0.1:3001/api/v1/mcp"
prepend-inner-icon="mdi-link-variant"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="server.headersText"
:label="t('setting.system.aiAgentMcpHeaders')"
:hint="t('setting.system.aiAgentMcpKeyValueHint')"
persistent-hint
rows="2"
auto-grow
prepend-inner-icon="mdi-format-align-left"
/>
</VCol>
</template>
</VRow>
<VAlert
v-if="testStates[server.id]?.message"
:type="testStates[server.id]?.success ? 'success' : 'error'"
variant="tonal"
density="comfortable"
class="mt-3"
>
<div>{{ testStates[server.id]?.message }}</div>
<div v-if="testStates[server.id]?.tools?.length" class="agent-mcp-tool-list mt-2">
<VChip
v-for="tool in testStates[server.id]?.tools"
:key="tool.agent_tool_name"
size="small"
variant="tonal"
>
{{ tool.agent_tool_name }}
</VChip>
</div>
</VAlert>
<div class="agent-mcp-actions mt-4">
<VBtn
color="info"
variant="tonal"
prepend-icon="mdi-connection"
:loading="testStates[server.id]?.loading"
@click="testServer(server)"
>
{{ t('setting.system.aiAgentMcpTest') }}
</VBtn>
<VBtn color="error" variant="text" prepend-icon="mdi-delete-outline" @click="removeServer(server)">
{{ t('common.delete') }}
</VBtn>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</VCardText>
<VCardActions class="app-dialog-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
prepend-icon="mdi-content-save"
class="px-5"
:loading="saving"
@click="saveServers"
>
{{ t('common.save') }}
</VBtn>
</VCardActions>
</VCard>
</VDialog>
</template>
<style lang="scss" scoped>
.agent-mcp-panel-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.agent-mcp-actions,
.agent-mcp-tool-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
</style>

View File

@@ -5,7 +5,7 @@ import { useToast } from 'vue-toastification'
const { t } = useI18n()
const toast = useToast()
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
const lastConnectionPromptKey = ref('')
const shownConnectionPromptKeys = new Set<string>()
const isChecking = computed(() => connectionStatus.value === 'checking')
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
@@ -36,17 +36,21 @@ function showConnectionPrompt() {
toast.error(message, options)
}
/** 在连接状态变化时发出一次离线提示,并在恢复在线后允许下一轮提示重新出现。 */
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
function handleConnectionStatusChange() {
if (connectionStatus.value === 'online') {
lastConnectionPromptKey.value = ''
shownConnectionPromptKeys.clear()
return
}
const promptKey = `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
if (promptKey === lastConnectionPromptKey.value) return
const promptKey =
connectionStatus.value === 'checking'
? connectionStatus.value
: `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
lastConnectionPromptKey.value = promptKey
if (shownConnectionPromptKeys.has(promptKey)) return
shownConnectionPromptKeys.add(promptKey)
showConnectionPrompt()
}

View File

@@ -1741,6 +1741,38 @@ export default {
aiAgentJobInterval24h: '24 Hours',
aiAgentJobInterval1w: '1 Week',
aiAgentJobInterval1M: '1 Month',
aiAgentMcpTitle: 'External MCP Tools',
aiAgentMcpSummary: '{total} MCP servers configured, {enabled} enabled.',
aiAgentMcpSettings: 'Set MCP',
aiAgentMcpDialogTitle: 'Agent MCP Settings',
aiAgentMcpDialogDesc: 'Configure external MCP servers and test tools available to the AI assistant.',
aiAgentMcpAddServer: 'Add Server',
aiAgentMcpEmpty: 'No MCP servers configured yet.',
aiAgentMcpNewServer: 'New MCP Server',
aiAgentMcpUnnamedServer: 'Unnamed Server',
aiAgentMcpName: 'Server Name',
aiAgentMcpTransport: 'Transport',
aiAgentMcpTransportStdio: 'stdio',
aiAgentMcpTransportSse: 'SSE',
aiAgentMcpTransportHttp: 'HTTP',
aiAgentMcpEnabled: 'Enable Server',
aiAgentMcpEnabledHint: 'When enabled, the AI assistant loads tools from this server at runtime',
aiAgentMcpRequireAdmin: 'Admin Only',
aiAgentMcpRequireAdminHint: 'Recommended for external tools that may have elevated permissions',
aiAgentMcpTimeout: 'Timeout (seconds)',
aiAgentMcpToolPrefix: 'Tool Prefix',
aiAgentMcpToolPrefixHint: 'Leave empty to derive it from the server name and avoid tool name conflicts',
aiAgentMcpDescription: 'Description',
aiAgentMcpCommand: 'Command',
aiAgentMcpArgs: 'Arguments',
aiAgentMcpArgsHint: 'One argument per line, such as -y or server-filesystem',
aiAgentMcpEnv: 'Environment Variables',
aiAgentMcpKeyValueHint: 'One KEY=VALUE pair per line',
aiAgentMcpUrl: 'Service URL',
aiAgentMcpHeaders: 'Headers',
aiAgentMcpTest: 'Test Connection',
aiAgentMcpSaveSuccess: 'MCP configuration saved',
aiAgentMcpSaveFailed: 'Failed to save MCP configuration',
advancedSettings: 'Advanced Settings',
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
downloaders: 'Downloaders',

View File

@@ -1723,6 +1723,38 @@ export default {
aiAgentJobInterval24h: '24小时',
aiAgentJobInterval1w: '1周',
aiAgentJobInterval1M: '1个月',
aiAgentMcpTitle: '外部 MCP 工具',
aiAgentMcpSummary: '已配置 {total} 个 MCP 服务器,启用 {enabled} 个。',
aiAgentMcpSettings: '设置 MCP',
aiAgentMcpDialogTitle: 'Agent MCP 设置',
aiAgentMcpDialogDesc: '配置外部 MCP 服务器,并测试可提供给智能助手的工具。',
aiAgentMcpAddServer: '添加服务器',
aiAgentMcpEmpty: '尚未配置 MCP 服务器。',
aiAgentMcpNewServer: '新的 MCP 服务器',
aiAgentMcpUnnamedServer: '未命名服务器',
aiAgentMcpName: '服务器名称',
aiAgentMcpTransport: '传输协议',
aiAgentMcpTransportStdio: 'stdio',
aiAgentMcpTransportSse: 'SSE',
aiAgentMcpTransportHttp: 'HTTP',
aiAgentMcpEnabled: '启用服务器',
aiAgentMcpEnabledHint: '启用后,智能助手会在运行时加载该服务器提供的工具',
aiAgentMcpRequireAdmin: '仅管理员可调用',
aiAgentMcpRequireAdminHint: '建议保持开启,避免普通用户调用高权限外部工具',
aiAgentMcpTimeout: '超时时间(秒)',
aiAgentMcpToolPrefix: '工具名前缀',
aiAgentMcpToolPrefixHint: '留空时会根据服务器名称自动生成,避免不同服务器工具名冲突',
aiAgentMcpDescription: '说明',
aiAgentMcpCommand: '启动命令',
aiAgentMcpArgs: '启动参数',
aiAgentMcpArgsHint: '每行一个参数,例如 -y 或 server-filesystem',
aiAgentMcpEnv: '环境变量',
aiAgentMcpKeyValueHint: '每行一个 KEY=VALUE',
aiAgentMcpUrl: '服务地址',
aiAgentMcpHeaders: '请求头',
aiAgentMcpTest: '测试连接',
aiAgentMcpSaveSuccess: 'MCP 配置已保存',
aiAgentMcpSaveFailed: 'MCP 配置保存失败',
advancedSettings: '高级设置',
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
downloaders: '下载器',

View File

@@ -1722,6 +1722,38 @@ export default {
aiAgentJobInterval24h: '24小時',
aiAgentJobInterval1w: '1週',
aiAgentJobInterval1M: '1個月',
aiAgentMcpTitle: '外部 MCP 工具',
aiAgentMcpSummary: '已配置 {total} 個 MCP 伺服器,啟用 {enabled} 個。',
aiAgentMcpSettings: '設置 MCP',
aiAgentMcpDialogTitle: 'Agent MCP 設置',
aiAgentMcpDialogDesc: '配置外部 MCP 伺服器,並測試可提供給智能助手的工具。',
aiAgentMcpAddServer: '添加伺服器',
aiAgentMcpEmpty: '尚未配置 MCP 伺服器。',
aiAgentMcpNewServer: '新的 MCP 伺服器',
aiAgentMcpUnnamedServer: '未命名伺服器',
aiAgentMcpName: '伺服器名稱',
aiAgentMcpTransport: '傳輸協議',
aiAgentMcpTransportStdio: 'stdio',
aiAgentMcpTransportSse: 'SSE',
aiAgentMcpTransportHttp: 'HTTP',
aiAgentMcpEnabled: '啟用伺服器',
aiAgentMcpEnabledHint: '啟用後,智能助手會在運行時載入該伺服器提供的工具',
aiAgentMcpRequireAdmin: '僅管理員可調用',
aiAgentMcpRequireAdminHint: '建議保持開啟,避免普通用戶調用高權限外部工具',
aiAgentMcpTimeout: '超時時間(秒)',
aiAgentMcpToolPrefix: '工具名稱前綴',
aiAgentMcpToolPrefixHint: '留空時會根據伺服器名稱自動生成,避免不同伺服器工具名衝突',
aiAgentMcpDescription: '說明',
aiAgentMcpCommand: '啟動命令',
aiAgentMcpArgs: '啟動參數',
aiAgentMcpArgsHint: '每行一個參數,例如 -y 或 server-filesystem',
aiAgentMcpEnv: '環境變數',
aiAgentMcpKeyValueHint: '每行一個 KEY=VALUE',
aiAgentMcpUrl: '服務地址',
aiAgentMcpHeaders: '請求頭',
aiAgentMcpTest: '測試連接',
aiAgentMcpSaveSuccess: 'MCP 配置已保存',
aiAgentMcpSaveFailed: 'MCP 配置保存失敗',
advancedSettings: '高級設置',
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
downloaders: '下載器',

View File

@@ -121,12 +121,15 @@ const isPersistingDashboardGridLayoutFromGrid = ref(false)
// 仪表板本地布局覆盖配置
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
// 最近一次已确认持久化的仪表板布局,用于编辑模式下避开临时布局草稿。
let persistedDashboardGridLayout: DashboardGridLayoutConfig = {}
// 是否处于“恢复默认布局”的临时草稿,确认前保持清空布局覆盖的语义。
const isDashboardGridLayoutResetDraft = ref(false)
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
const isDashboardGridLayoutResetPending = ref(false)
// 旧版跨设备显示项配置,仅用于首次迁移到按设备拆分的仪表盘配置。
let legacyDashboardEnableConfig: DashboardEnableConfig | undefined
let isLegacyDashboardEnableConfigLoaded = false
@@ -720,23 +723,34 @@ async function loadSharedDashboardConfig<T>(
return localConfig
}
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
function saveDashboardProfileConfig(layout = dashboardGridLayout.value, enabled = enableConfig.value) {
// 保存指定布局或当前已确认布局到本地和用户配置。
function saveDashboardProfileConfig(layout?: DashboardGridLayoutConfig, enabled = enableConfig.value) {
const profile = dashboardLayoutProfile.value
const profileConfig = buildDashboardProfileConfig(layout, enabled)
const layoutToPersist = layout ?? (isLayoutEditing.value ? persistedDashboardGridLayout : dashboardGridLayout.value)
const profileConfig = buildDashboardProfileConfig(layoutToPersist, enabled)
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), profileConfig)
persistedDashboardGridLayout = cloneDashboardGridLayout(layoutToPersist)
return queueDashboardProfileRemoteSave(getDashboardGridLayoutConfigKey(profile), profileConfig).catch(error =>
console.error(error),
)
}
// 将当前仪表板布局覆盖配置保存到本地和用户配置
// 持久化指定的仪表板布局覆盖配置。
function saveDashboardGridLayout(layout: DashboardGridLayoutConfig) {
return saveDashboardProfileConfig(layout)
}
// 克隆仪表板布局配置,避免临时编辑草稿和已确认布局共用对象引用。
function cloneDashboardGridLayout(layout: DashboardGridLayoutConfig): DashboardGridLayoutConfig {
return Object.entries(layout).reduce<DashboardGridLayoutConfig>((clonedLayout, [id, itemLayout]) => {
clonedLayout[id] = { ...itemLayout }
return clonedLayout
}, {})
}
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
function getDefaultDashboardGridWidth(item: DashboardItem) {
const profile = dashboardLayoutProfile.value
@@ -818,10 +832,11 @@ function updateDashboardSettingsDialog() {
})
}
// 退出仪表板布局编辑模式;如果刚恢复默认布局,则跳过本次本地持久化。
// 退出仪表板布局编辑模式;用户点击确认时才把临时布局草稿持久化。
async function exitDashboardLayoutEditing() {
if (isDashboardGridLayoutResetPending.value) {
isDashboardGridLayoutResetPending.value = false
if (isDashboardGridLayoutResetDraft.value) {
await saveDashboardGridLayout(dashboardGridLayout.value)
isDashboardGridLayoutResetDraft.value = false
} else {
await persistCurrentDashboardGridLayout()
}
@@ -834,21 +849,21 @@ async function exitDashboardLayoutEditing() {
notifyDashboardContentResize()
}
// 清除用户本地布局覆盖并恢复内置组件和插件声明的默认占位,然后退出编辑模式
// 清除用户布局覆盖并恢复默认占位;编辑中仅写入临时草稿并等待确认持久化
async function resetDashboardGridLayout() {
const shouldPersistImmediately = !isLayoutEditing.value
dashboardGridLayout.value = {}
await saveDashboardGridLayout({})
dashboardGrid.value?.removeAll(false, false)
isDashboardGridLayoutResetPending.value = true
await syncDashboardGrid()
if (isLayoutEditing.value) {
await exitDashboardLayoutEditing()
} else {
await nextTick()
syncDashboardFillContentState()
resizeAutoDashboardItemsToContent()
notifyDashboardContentResize()
if (shouldPersistImmediately) {
await saveDashboardGridLayout({})
}
isDashboardGridLayoutResetDraft.value = isLayoutEditing.value
dashboardGrid.value?.removeAll(false, false)
await syncDashboardGrid()
await nextTick()
syncDashboardFillContentState()
resizeAutoDashboardItemsToContent()
notifyDashboardContentResize()
}
// 生成 appMode 底部动态按钮菜单,普通 Web 模式由页面内 FAB 承接。
@@ -893,14 +908,14 @@ useDynamicButton({
show: computed(() => appMode.value && route.path === '/dashboard'),
})
// 切换仪表板布局编辑模式,退出编辑时保存当前布局。
// 切换仪表板布局编辑模式,点击确认退出时持久化当前布局草稿
function toggleDashboardLayoutEditing() {
if (isLayoutEditing.value) {
void exitDashboardLayoutEditing()
return
}
isDashboardGridLayoutResetPending.value = false
isDashboardGridLayoutResetDraft.value = false
isLayoutEditing.value = true
nextTick(syncDashboardGrid)
}
@@ -920,7 +935,10 @@ async function loadDashboardConfig() {
// 设备档位配置同时承载 Grid 布局和显示项,显示项缺失时从旧版全局配置迁移。
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
dashboardGridLayout.value = profileConfig?.items ?? {}
const loadedLayout = profileConfig?.items ?? {}
dashboardGridLayout.value = loadedLayout
persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout)
isDashboardGridLayoutResetDraft.value = false
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
await saveDashboardProfileConfig()
@@ -1286,12 +1304,12 @@ function handleDashboardGridResize() {
notifyDashboardContentResize()
}
// 存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。
// 存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。
function handleDashboardGridDragStop() {
void persistCurrentDashboardGridLayout(false)
void cacheCurrentDashboardGridLayout(false)
}
// 存用户缩放后的布局,只有高度发生变化时才把高度标记为手动固定。
// 存用户缩放后的布局,只有高度发生变化时才把高度标记为手动固定。
function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLElement) {
const id = element.getAttribute('gs-id') ?? ''
const previousHeight = dashboardGridResizeStartHeights.get(id)
@@ -1301,7 +1319,7 @@ function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLEleme
dashboardGridResizeStartHeights.delete(id)
isDashboardGridResizing.value = false
notifyDashboardContentResize()
void persistCurrentDashboardGridLayout(heightChanged ? id : false)
void cacheCurrentDashboardGridLayout(heightChanged ? id : false)
}
// 合并连续 resize 通知,模拟浏览器窗口变化让组件内部内容自适配新尺寸。
@@ -1314,9 +1332,9 @@ function notifyDashboardContentResize() {
})
}
// 将 GridStack 保存结果归一化为本地布局覆盖表
async function persistDashboardGridLayout(manualHeightId: string | false = false) {
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
// 将 GridStack 保存结果归一化为临时布局草稿
function cacheDashboardGridLayoutDraft(manualHeightId: string | false = false) {
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return undefined
const gridColumns = getCurrentDashboardGridColumns()
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
@@ -1344,13 +1362,13 @@ async function persistDashboardGridLayout(manualHeightId: string | false = false
isPersistingDashboardGridLayoutFromGrid.value = true
dashboardGridLayout.value = nextLayout
const savePromise = saveDashboardGridLayout(nextLayout)
isDashboardGridLayoutResetDraft.value = false
nextTick(() => {
isPersistingDashboardGridLayoutFromGrid.value = false
resizeAutoDashboardItemsToContent()
})
await savePromise
return nextLayout
}
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
@@ -1360,13 +1378,20 @@ function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRI
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
}
// 等待 GridStack 当前拖拽/缩放事件收尾后,保存用户当前看到的布局。
async function persistCurrentDashboardGridLayout(manualHeightId: string | false = false) {
// 等待 GridStack 当前拖拽/缩放事件收尾后,用户当前看到的布局写入临时草稿
async function cacheCurrentDashboardGridLayout(manualHeightId: string | false = false) {
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
isDashboardGridLayoutResetPending.value = false
await nextTick()
await persistDashboardGridLayout(manualHeightId)
return cacheDashboardGridLayoutDraft(manualHeightId)
}
// 等待 GridStack 当前拖拽/缩放事件收尾后,持久化用户确认的临时布局草稿。
async function persistCurrentDashboardGridLayout(manualHeightId: string | false = false) {
const nextLayout = await cacheCurrentDashboardGridLayout(manualHeightId)
if (!nextLayout) return
await saveDashboardGridLayout(nextLayout)
}
// 清理 GridStack 内部响应式布局缓存,并用当前 Vue 布局状态重新注册已有 DOM 节点。
@@ -1408,7 +1433,10 @@ watch(
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return
dashboardGridLayout.value = profileConfig?.items ?? {}
const loadedLayout = profileConfig?.items ?? {}
dashboardGridLayout.value = loadedLayout
persistedDashboardGridLayout = cloneDashboardGridLayout(loadedLayout)
isDashboardGridLayoutResetDraft.value = false
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
await saveDashboardProfileConfig()

View File

@@ -604,6 +604,11 @@ function getExistText(season: number) {
else return t('media.status.inLibrary')
}
// 判断指定季集是否已存在于媒体服务器
function isEpisodeExists(season: number, episode: number) {
return existsEpisodes.value[season]?.includes(episode) ?? false
}
// 计算订阅图标
const getSubscribeIcon = computed(() => {
if (mediaDetail.value.type === '电视剧') return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
@@ -1036,7 +1041,14 @@ onUnmounted(() => {
:key="episode.episode_number"
class="flex flex-col space-y-4 py-4 xl:flex-row xl:space-y-4 xl:space-x-4"
>
<div class="flex-1">
<div class="episode-info flex-1">
<VIcon
v-if="isEpisodeExists(season.season_number || 0, episode.episode_number || 0)"
color="success"
icon="mdi-check-circle"
class="episode-exists-badge"
size="small"
/>
<div class="flex flex-col space-y-2 lg:flex-row lg:items-center lg:space-y-0 lg:space-x-2">
<h3 class="text-lg">{{ episode.episode_number }} - {{ episode.name }}</h3>
<div class="flex items-center space-x-2">
@@ -1532,6 +1544,18 @@ a.crew-name {
min-inline-size: 0;
}
.episode-info {
position: relative;
min-inline-size: 0;
padding-inline-end: 2rem;
}
.episode-exists-badge {
position: absolute;
inset-block-start: 0.125rem;
inset-inline-end: 0;
}
.episode-group-label {
color: rgba(var(--v-theme-on-surface), 0.72);
font-size: 0.75rem;

View File

@@ -33,6 +33,7 @@ const props = defineProps({
// 下载器/媒体服务器排序按需加载,降低系统设置页入口解析量。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const LlmProviderAuthDialog = defineAsyncComponent(() => import('@/components/dialog/LlmProviderAuthDialog.vue'))
const AgentMcpSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/AgentMcpSettingsDialog.vue'))
// 系统设置项
const SystemSettings = ref<any>({
@@ -208,6 +209,9 @@ const advancedDialog = ref(false)
const savingBasic = ref(false)
const testingLlm = ref(false)
const rustAccelAvailable = ref(false)
const agentMcpDialog = ref(false)
const agentMcpServers = ref<AgentMcpServer[]>([])
const loadingAgentMcpServers = ref(false)
// 智能助手配置项较多,默认收起以降低基础设置页的视觉占用。
const aiAgentSettingsCollapsed = ref(true)
@@ -225,6 +229,24 @@ type LlmSettingsSnapshot = {
LLM_TEMPERATURE: number
}
type AgentMcpTransport = 'stdio' | 'sse' | 'http' | 'streamable_http'
interface AgentMcpServer {
id: string
name: string
enabled: boolean
transport: AgentMcpTransport
description?: string | null
command?: string | null
args: string[]
env: Record<string, string>
url?: string | null
headers: Record<string, string>
timeout: number
tool_prefix?: string | null
require_admin: boolean
}
let llmTestRequestId = 0
let llmTestAbortController: AbortController | null = null
@@ -461,6 +483,8 @@ const selectedLlmModelInfo = computed(() => {
source: selectedLlmModel.value.source || 'models.dev',
})
})
const agentMcpEnabledCount = computed(() => agentMcpServers.value.filter(server => server.enabled).length)
const agentMcpServerPreview = computed(() => agentMcpServers.value.slice(0, 3))
const canTestLlm = computed(() => {
const snapshot = currentLlmSnapshot.value
@@ -682,6 +706,23 @@ async function loadSystemSettings() {
} catch (error) {
console.log(error)
}
await loadAgentMcpServers()
}
async function loadAgentMcpServers() {
loadingAgentMcpServers.value = true
try {
const result: { [key: string]: any } = await api.get('message/agent/mcp/servers')
if (result.success) agentMcpServers.value = result.data?.servers || []
} catch (error) {
console.log(error)
} finally {
loadingAgentMcpServers.value = false
}
}
function handleAgentMcpSaved(servers: AgentMcpServer[]) {
agentMcpServers.value = servers
}
// 调用API保存设置
@@ -1409,6 +1450,46 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-timer-outline"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12">
<VAlert type="info" variant="tonal" class="agent-mcp-summary">
<div class="agent-mcp-summary__content">
<div>
<div class="text-subtitle-2">{{ t('setting.system.aiAgentMcpTitle') }}</div>
<div class="text-body-2">
{{
t('setting.system.aiAgentMcpSummary', {
enabled: agentMcpEnabledCount,
total: agentMcpServers.length,
})
}}
</div>
<div v-if="agentMcpServers.length" class="agent-mcp-summary__chips mt-2">
<VChip
v-for="server in agentMcpServerPreview"
:key="server.id"
size="small"
variant="tonal"
:color="server.enabled ? 'success' : 'default'"
>
{{ server.name }}
</VChip>
<VChip v-if="agentMcpServers.length > 3" size="small" variant="tonal">
+{{ agentMcpServers.length - 3 }}
</VChip>
</div>
</div>
<VBtn
color="primary"
variant="tonal"
prepend-icon="mdi-server-network"
:loading="loadingAgentMcpServers"
@click="agentMcpDialog = true"
>
{{ t('setting.system.aiAgentMcpSettings') }}
</VBtn>
</div>
</VAlert>
</VCol>
</VRow>
<VRow>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
@@ -1768,6 +1849,13 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</VCol>
</VRow>
<AgentMcpSettingsDialog
v-if="agentMcpDialog"
v-model="agentMcpDialog"
:servers="agentMcpServers"
@saved="handleAgentMcpSaved"
/>
<!-- 高级系统设置 -->
<VDialog
v-if="advancedDialog"
@@ -2477,4 +2565,24 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
.llm-test-trigger {
min-inline-size: 0;
}
.agent-mcp-summary__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.agent-mcp-summary__chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 600px) {
.agent-mcp-summary__content {
align-items: stretch;
flex-direction: column;
}
}
</style>