mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-10 23:13:20 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c4511f935 | ||
|
|
212ba94e11 | ||
|
|
f863a09212 | ||
|
|
728ced475b | ||
|
|
a192e00db5 | ||
|
|
85e6b17db7 | ||
|
|
156d835d6d |
526
.github/workflows/pr-agent.yml
vendored
526
.github/workflows/pr-agent.yml
vendored
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.14.2",
|
||||
"version": "2.14.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
449
src/components/dialog/AgentMcpSettingsDialog.vue
Normal file
449
src/components/dialog/AgentMcpSettingsDialog.vue
Normal 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>
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '下载器',
|
||||
|
||||
@@ -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: '下載器',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user