mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-11 23:42:41 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7861afb2d7 | ||
|
|
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
|
- review_requested
|
||||||
- synchronize
|
- synchronize
|
||||||
issue_comment:
|
issue_comment:
|
||||||
# 手动命令如 "/review"、"/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
# 手动命令如 "/describe"、"/improve" 和 "/ask ..." 只在 PR 评论中有意义。
|
||||||
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
# issue_comment 同时覆盖普通 issue,因此 job 里还会再判断是否属于 PR。
|
||||||
types:
|
types:
|
||||||
- created
|
- created
|
||||||
@@ -27,8 +27,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pr-agent:
|
pr-agent:
|
||||||
name: PR-Agent review and describe
|
name: PR-Agent inline review
|
||||||
# PR 事件自动处理;评论命令仅允许指定身份在 PR 下触发,避免任意评论消耗模型配额。
|
|
||||||
if: >-
|
if: >-
|
||||||
github.event.sender.type != 'Bot' &&
|
github.event.sender.type != 'Bot' &&
|
||||||
(
|
(
|
||||||
@@ -38,8 +37,6 @@ jobs:
|
|||||||
github.event.issue.pull_request != null &&
|
github.event.issue.pull_request != null &&
|
||||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR", "FIRST_TIME_CONTRIBUTOR"]'), github.event.comment.author_association) &&
|
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' ||
|
github.event.comment.body == '/describe' ||
|
||||||
startsWith(github.event.comment.body, '/describe ') ||
|
startsWith(github.event.comment.body, '/describe ') ||
|
||||||
github.event.comment.body == '/improve' ||
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
steps:
|
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
|
- name: Run PR-Agent
|
||||||
id: pragent
|
id: pragent
|
||||||
|
if: steps.pr_language.outputs.skip_pr_agent != 'true'
|
||||||
# 使用版本号加 digest 固定容器构建,避免 tag 被重推后改变运行内容。
|
# 使用版本号加 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:
|
env:
|
||||||
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
# PR-Agent 使用该 token 读取 PR 元数据并发布评论。
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
@@ -72,52 +236,348 @@ jobs:
|
|||||||
config.fallback_models: '["gpt-5.4"]'
|
config.fallback_models: '["gpt-5.4"]'
|
||||||
config.reasoning_effort: "xhigh"
|
config.reasoning_effort: "xhigh"
|
||||||
config.ai_timeout: "900"
|
config.ai_timeout: "900"
|
||||||
config.response_language: "zh-CN"
|
config.response_language: ${{ steps.pr_language.outputs.response_language }}
|
||||||
config.large_patch_policy: "clip"
|
config.large_patch_policy: "clip"
|
||||||
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
config.ignore_pr_title: '["^\\[Auto\\]", "^Auto"]'
|
||||||
config.ignore_pr_labels: '["skip pr-agent"]'
|
config.ignore_pr_labels: '["skip pr-agent"]'
|
||||||
|
|
||||||
# pull_request_target 事件默认自动执行 /review 和 /describe;/improve 保持手动触发。
|
# PR 初次进入评审或后续 push 时,更新 PR 摘要并发布 GitHub Review 行内建议。
|
||||||
github_action_config.auto_review: "true"
|
github_action_config.auto_review: "false"
|
||||||
github_action_config.auto_describe: "true"
|
github_action_config.auto_describe: "true"
|
||||||
github_action_config.auto_improve: "false"
|
github_action_config.auto_improve: "true"
|
||||||
|
|
||||||
# 允许触发自动工具的 PR 动作。包含 synchronize,便于新 commit 推送后刷新结果。
|
# synchronize 由 push_commands 单独处理;每次 push 更新摘要和行内 Review,旧行评由 GitHub 标记 outdated。
|
||||||
github_action_config.pr_actions: '["opened", "reopened", "ready_for_review", "review_requested", "synchronize"]'
|
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 编排或排查。
|
# 保留 action outputs,便于后续 workflow 编排或排查。
|
||||||
github_action_config.enable_output: "true"
|
github_action_config.enable_output: "true"
|
||||||
|
|
||||||
# /describe 行为控制;与自动触发配置放在同一层,避免使用默认图表和标签策略。
|
# /describe 行为控制;只更新 PR body 中的 PR-Agent 摘要占位符。
|
||||||
pr_description.generate_ai_title: "false"
|
pr_description.generate_ai_title: "false"
|
||||||
pr_description.publish_labels: "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_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.collapsible_file_list: "adaptive"
|
||||||
pr_description.add_original_user_description: "true"
|
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 输出策略,聚焦维护者需要处理的风险和缺口。
|
# /improve 以 GitHub 内联建议呈现,便于像正式 review discussion 一样逐条处理。
|
||||||
pr_reviewer.extra_instructions: |
|
pr_code_suggestions.extra_instructions: |
|
||||||
请用中文输出。
|
Match the configured response language.
|
||||||
优先指出 P0/P1 风险,避免纠结纯格式问题。
|
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_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 的手动命令策略。
|
|
||||||
pr_code_suggestions.focus_only_on_problems: "true"
|
pr_code_suggestions.focus_only_on_problems: "true"
|
||||||
pr_code_suggestions.suggestions_score_threshold: "7"
|
pr_code_suggestions.suggestions_score_threshold: "3"
|
||||||
pr_code_suggestions.commitable_code_suggestions: "false"
|
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"
|
pr_questions.use_conversation_history: "true"
|
||||||
|
|
||||||
# 可选成本和噪音控制:
|
# 可选成本和噪音控制:
|
||||||
# github_action_config.auto_improve: "true"
|
# github_action_config.auto_improve: "true"
|
||||||
# config.verbosity_level: "1"
|
# 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",
|
"name": "moviepilot",
|
||||||
"version": "2.14.2",
|
"version": "2.14.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"bin": "dist/service.js",
|
"bin": "dist/service.js",
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ const buttonText = computed(() =>
|
|||||||
loading.value ? t('dialog.addDownload.downloading') : t('dialog.addDownload.startDownload'),
|
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() {
|
async function loadDirectories() {
|
||||||
try {
|
try {
|
||||||
@@ -78,6 +86,7 @@ async function loadDirectories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 将下载目录配置转换为下载器可识别的存储路径。
|
||||||
function convertToUri(item: TransferDirectoryConf) {
|
function convertToUri(item: TransferDirectoryConf) {
|
||||||
if (!item.download_path) {
|
if (!item.download_path) {
|
||||||
return undefined
|
return undefined
|
||||||
@@ -181,7 +190,7 @@ onMounted(() => {
|
|||||||
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
|
<VIcon icon="mdi-monitor-arrow-down-variant" class="me-2" />
|
||||||
</template>
|
</template>
|
||||||
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
|
<VCardTitle>{{ t('dialog.addDownload.confirmDownload') }}</VCardTitle>
|
||||||
<VCardSubtitle>{{ torrent?.site_name }} - {{ title }}</VCardSubtitle>
|
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||||||
</VCardItem>
|
</VCardItem>
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
<VDivider />
|
<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>
|
||||||
@@ -46,6 +46,14 @@ const isRefreshed = ref(false)
|
|||||||
// 渲染模式: 'vuetify' 或 'vue'
|
// 渲染模式: 'vuetify' 或 'vue'
|
||||||
const renderMode = ref('vuetify')
|
const renderMode = ref('vuetify')
|
||||||
|
|
||||||
|
// 插件未声明布局偏好时沿用标准配置弹窗宽度。
|
||||||
|
const dialogMaxWidth = ref('60rem')
|
||||||
|
|
||||||
|
interface PluginConfigLayout {
|
||||||
|
/** 插件配置界面期望的最大宽度,使用合法 CSS 尺寸。 */
|
||||||
|
maxWidth?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Vue 模式:动态加载的组件
|
// Vue 模式:动态加载的组件
|
||||||
const dynamicComponent = defineAsyncComponent({
|
const dynamicComponent = defineAsyncComponent({
|
||||||
// 工厂函数
|
// 工厂函数
|
||||||
@@ -89,6 +97,7 @@ async function loadPluginUIData() {
|
|||||||
pluginFormItems = []
|
pluginFormItems = []
|
||||||
pluginConfigForm.value = {}
|
pluginConfigForm.value = {}
|
||||||
renderMode.value = 'vuetify'
|
renderMode.value = 'vuetify'
|
||||||
|
dialogMaxWidth.value = '60rem'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 获取UI定义
|
// 获取UI定义
|
||||||
@@ -123,6 +132,12 @@ function handleVueComponentSave(newConfig: Record<string, any>) {
|
|||||||
savePluginConf()
|
savePluginConf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 联邦配置组件可按自身布局密度覆盖宿主弹窗宽度。
|
||||||
|
function handleVueComponentLayout(layout?: PluginConfigLayout | null) {
|
||||||
|
const maxWidth = typeof layout?.maxWidth === 'string' ? layout.maxWidth.trim() : ''
|
||||||
|
dialogMaxWidth.value = maxWidth || '60rem'
|
||||||
|
}
|
||||||
|
|
||||||
// 调用API保存配置数据
|
// 调用API保存配置数据
|
||||||
async function savePluginConf() {
|
async function savePluginConf() {
|
||||||
// 显示等待提示框
|
// 显示等待提示框
|
||||||
@@ -148,7 +163,7 @@ onBeforeMount(async () => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VDialog scrollable max-width="60rem" :fullscreen="!display.mdAndUp.value">
|
<VDialog scrollable :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
||||||
<!-- Vuetify 渲染模式 -->
|
<!-- Vuetify 渲染模式 -->
|
||||||
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
<VCard v-if="renderMode === 'vuetify'" :title="`${props.plugin?.plugin_name} - ${t('dialog.pluginConfig.title')}`">
|
||||||
<VDialogCloseBtn @click="emit('close')" />
|
<VDialogCloseBtn @click="emit('close')" />
|
||||||
@@ -192,6 +207,7 @@ onBeforeMount(async () => {
|
|||||||
:initial-config="pluginConfigForm"
|
:initial-config="pluginConfigForm"
|
||||||
:api="api"
|
:api="api"
|
||||||
@save="handleVueComponentSave"
|
@save="handleVueComponentSave"
|
||||||
|
@layout="handleVueComponentLayout"
|
||||||
@switch="emit('switch')"
|
@switch="emit('switch')"
|
||||||
@close="emit('close')"
|
@close="emit('close')"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: '* * * * *',
|
default: '* * * * *',
|
||||||
},
|
},
|
||||||
|
/** 是否允许直接清空 CRON。 */
|
||||||
|
clearable: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
@@ -39,7 +44,6 @@ function updateModelValue(value: string) {
|
|||||||
:modelValue="innerValue"
|
:modelValue="innerValue"
|
||||||
@update:modelValue="updateModelValue"
|
@update:modelValue="updateModelValue"
|
||||||
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
|
v-bind="{ ...menuprops, ...propsWithoutModelValue }"
|
||||||
clearable
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</CronInput>
|
</CronInput>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const menu = ref(false)
|
const menu = ref(false)
|
||||||
const currentCron = ref(props.modelValue)
|
const currentCron = ref(props.modelValue)
|
||||||
@@ -14,6 +15,27 @@ const menuRoot = ref<HTMLElement>()
|
|||||||
const instance = getCurrentInstance()
|
const instance = getCurrentInstance()
|
||||||
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
|
const menuContentClass = `cron-input-menu-${instance?.uid ?? 'default'}`
|
||||||
const menuContentSelector = `.${menuContentClass}`
|
const menuContentSelector = `.${menuContentClass}`
|
||||||
|
const normalizedLocale = computed(() => locale.value.toLowerCase().replace(/_/g, '-'))
|
||||||
|
const cronLocale = computed(() => normalizedLocale.value.startsWith('zh') ? 'zh-cn' : 'en')
|
||||||
|
// vue-js-cron 没有内置繁体中文,只需覆盖简体词典中会显示的 Cron 专有字词。
|
||||||
|
const cronCustomLocale = computed(() => normalizedLocale.value === 'zh-tw' ? {
|
||||||
|
'*': {
|
||||||
|
day: {
|
||||||
|
value: { text: '{{value.alt}}號' },
|
||||||
|
range: { text: '{{start.alt}}號-{{end.alt}}號' },
|
||||||
|
},
|
||||||
|
dayOfWeek: { empty: { text: '一週的每一天' } },
|
||||||
|
hour: { empty: { text: '每小時' } },
|
||||||
|
minute: { empty: { text: '每分鐘' } },
|
||||||
|
},
|
||||||
|
hour: {
|
||||||
|
text: '小時',
|
||||||
|
minute: { '*': { suffix: '分鐘' } },
|
||||||
|
},
|
||||||
|
week: { text: '週' },
|
||||||
|
'q-minute': { text: '分鐘' },
|
||||||
|
'q-hour': { text: '小時' },
|
||||||
|
} : undefined)
|
||||||
|
|
||||||
function isCronMenuTarget(target: EventTarget | null) {
|
function isCronMenuTarget(target: EventTarget | null) {
|
||||||
if (!(target instanceof Element)) return false
|
if (!(target instanceof Element)) return false
|
||||||
@@ -72,7 +94,14 @@ watch(
|
|||||||
</template>
|
</template>
|
||||||
<VList>
|
<VList>
|
||||||
<VListItem>
|
<VListItem>
|
||||||
<VCronVuetify v-model="currentCron" locale="zh-CN" :chip-props="{ color: 'success' }" class="mt-1" />
|
<VCronVuetify
|
||||||
|
:key="locale"
|
||||||
|
v-model="currentCron"
|
||||||
|
:chip-props="{ color: 'success' }"
|
||||||
|
class="mt-1"
|
||||||
|
:custom-locale="cronCustomLocale"
|
||||||
|
:locale="cronLocale"
|
||||||
|
/>
|
||||||
</VListItem>
|
</VListItem>
|
||||||
</VList>
|
</VList>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useToast } from 'vue-toastification'
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
|
const { connectionStatus, connectionReason } = useGlobalOfflineStatus()
|
||||||
const lastConnectionPromptKey = ref('')
|
const shownConnectionPromptKeys = new Set<string>()
|
||||||
|
|
||||||
const isChecking = computed(() => connectionStatus.value === 'checking')
|
const isChecking = computed(() => connectionStatus.value === 'checking')
|
||||||
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
|
const statusTitle = computed(() => (isChecking.value ? t('app.connectionChecking') : t('app.serviceUnavailable')))
|
||||||
@@ -36,17 +36,21 @@ function showConnectionPrompt() {
|
|||||||
toast.error(message, options)
|
toast.error(message, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在连接状态变化时发出一次离线提示,并在恢复在线后允许下一轮提示重新出现。 */
|
/** 在同一轮连接异常内按状态去重提示,并在恢复在线后允许下一轮提示重新出现。 */
|
||||||
function handleConnectionStatusChange() {
|
function handleConnectionStatusChange() {
|
||||||
if (connectionStatus.value === 'online') {
|
if (connectionStatus.value === 'online') {
|
||||||
lastConnectionPromptKey.value = ''
|
shownConnectionPromptKeys.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const promptKey = `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
|
const promptKey =
|
||||||
if (promptKey === lastConnectionPromptKey.value) return
|
connectionStatus.value === 'checking'
|
||||||
|
? connectionStatus.value
|
||||||
|
: `${connectionStatus.value}:${connectionReason.value || 'unknown'}`
|
||||||
|
|
||||||
lastConnectionPromptKey.value = promptKey
|
if (shownConnectionPromptKeys.has(promptKey)) return
|
||||||
|
|
||||||
|
shownConnectionPromptKeys.add(promptKey)
|
||||||
showConnectionPrompt()
|
showConnectionPrompt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1741,6 +1741,38 @@ export default {
|
|||||||
aiAgentJobInterval24h: '24 Hours',
|
aiAgentJobInterval24h: '24 Hours',
|
||||||
aiAgentJobInterval1w: '1 Week',
|
aiAgentJobInterval1w: '1 Week',
|
||||||
aiAgentJobInterval1M: '1 Month',
|
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',
|
advancedSettings: 'Advanced Settings',
|
||||||
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
advancedSettingsDesc: 'System advanced settings, only need to be adjusted in special cases',
|
||||||
downloaders: 'Downloaders',
|
downloaders: 'Downloaders',
|
||||||
|
|||||||
@@ -1723,6 +1723,38 @@ export default {
|
|||||||
aiAgentJobInterval24h: '24小时',
|
aiAgentJobInterval24h: '24小时',
|
||||||
aiAgentJobInterval1w: '1周',
|
aiAgentJobInterval1w: '1周',
|
||||||
aiAgentJobInterval1M: '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: '高级设置',
|
advancedSettings: '高级设置',
|
||||||
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
advancedSettingsDesc: '系统进阶设置,特殊情况下才需要调整',
|
||||||
downloaders: '下载器',
|
downloaders: '下载器',
|
||||||
|
|||||||
@@ -1722,6 +1722,38 @@ export default {
|
|||||||
aiAgentJobInterval24h: '24小時',
|
aiAgentJobInterval24h: '24小時',
|
||||||
aiAgentJobInterval1w: '1週',
|
aiAgentJobInterval1w: '1週',
|
||||||
aiAgentJobInterval1M: '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: '高級設置',
|
advancedSettings: '高級設置',
|
||||||
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
advancedSettingsDesc: '系統進階設置,特殊情況下才需要調整',
|
||||||
downloaders: '下載器',
|
downloaders: '下載器',
|
||||||
|
|||||||
@@ -121,12 +121,15 @@ const isPersistingDashboardGridLayoutFromGrid = ref(false)
|
|||||||
// 仪表板本地布局覆盖配置
|
// 仪表板本地布局覆盖配置
|
||||||
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
|
const dashboardGridLayout = ref<DashboardGridLayoutConfig>({})
|
||||||
|
|
||||||
|
// 最近一次已确认持久化的仪表板布局,用于编辑模式下避开临时布局草稿。
|
||||||
|
let persistedDashboardGridLayout: DashboardGridLayoutConfig = {}
|
||||||
|
|
||||||
|
// 是否处于“恢复默认布局”的临时草稿,确认前保持清空布局覆盖的语义。
|
||||||
|
const isDashboardGridLayoutResetDraft = ref(false)
|
||||||
|
|
||||||
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
|
// 当前仪表板布局档位,按 GridStack 响应式列数拆分跨端配置。
|
||||||
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
|
const dashboardLayoutProfile = ref<DashboardLayoutProfile>('desktop')
|
||||||
|
|
||||||
// 是否刚恢复过默认布局,用于避免退出编辑时立即把默认布局写回本地覆盖。
|
|
||||||
const isDashboardGridLayoutResetPending = ref(false)
|
|
||||||
|
|
||||||
// 旧版跨设备显示项配置,仅用于首次迁移到按设备拆分的仪表盘配置。
|
// 旧版跨设备显示项配置,仅用于首次迁移到按设备拆分的仪表盘配置。
|
||||||
let legacyDashboardEnableConfig: DashboardEnableConfig | undefined
|
let legacyDashboardEnableConfig: DashboardEnableConfig | undefined
|
||||||
let isLegacyDashboardEnableConfigLoaded = false
|
let isLegacyDashboardEnableConfigLoaded = false
|
||||||
@@ -720,23 +723,34 @@ async function loadSharedDashboardConfig<T>(
|
|||||||
return localConfig
|
return localConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
|
// 保存指定布局或当前已确认布局到本地和用户配置。
|
||||||
function saveDashboardProfileConfig(layout = dashboardGridLayout.value, enabled = enableConfig.value) {
|
function saveDashboardProfileConfig(layout?: DashboardGridLayoutConfig, enabled = enableConfig.value) {
|
||||||
const profile = dashboardLayoutProfile.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)
|
saveLocalDashboardConfig(getDashboardGridLayoutStorageKey(profile), profileConfig)
|
||||||
|
persistedDashboardGridLayout = cloneDashboardGridLayout(layoutToPersist)
|
||||||
|
|
||||||
return queueDashboardProfileRemoteSave(getDashboardGridLayoutConfigKey(profile), profileConfig).catch(error =>
|
return queueDashboardProfileRemoteSave(getDashboardGridLayoutConfigKey(profile), profileConfig).catch(error =>
|
||||||
console.error(error),
|
console.error(error),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将当前仪表板布局覆盖配置保存到本地和用户配置。
|
// 持久化指定的仪表板布局覆盖配置。
|
||||||
function saveDashboardGridLayout(layout: DashboardGridLayoutConfig) {
|
function saveDashboardGridLayout(layout: DashboardGridLayoutConfig) {
|
||||||
return saveDashboardProfileConfig(layout)
|
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 配置。
|
// 获取仪表板组件的默认宽度,优先兼容插件旧版 cols.md / cols.cols 配置。
|
||||||
function getDefaultDashboardGridWidth(item: DashboardItem) {
|
function getDefaultDashboardGridWidth(item: DashboardItem) {
|
||||||
const profile = dashboardLayoutProfile.value
|
const profile = dashboardLayoutProfile.value
|
||||||
@@ -818,10 +832,11 @@ function updateDashboardSettingsDialog() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 退出仪表板布局编辑模式;如果刚恢复默认布局,则跳过本次本地持久化。
|
// 退出仪表板布局编辑模式;用户点击确认时才把临时布局草稿持久化。
|
||||||
async function exitDashboardLayoutEditing() {
|
async function exitDashboardLayoutEditing() {
|
||||||
if (isDashboardGridLayoutResetPending.value) {
|
if (isDashboardGridLayoutResetDraft.value) {
|
||||||
isDashboardGridLayoutResetPending.value = false
|
await saveDashboardGridLayout(dashboardGridLayout.value)
|
||||||
|
isDashboardGridLayoutResetDraft.value = false
|
||||||
} else {
|
} else {
|
||||||
await persistCurrentDashboardGridLayout()
|
await persistCurrentDashboardGridLayout()
|
||||||
}
|
}
|
||||||
@@ -834,21 +849,21 @@ async function exitDashboardLayoutEditing() {
|
|||||||
notifyDashboardContentResize()
|
notifyDashboardContentResize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除用户本地布局覆盖,并恢复内置组件和插件声明的默认占位,然后退出编辑模式。
|
// 清除用户布局覆盖并恢复默认占位;编辑中仅写入临时草稿并等待确认持久化。
|
||||||
async function resetDashboardGridLayout() {
|
async function resetDashboardGridLayout() {
|
||||||
|
const shouldPersistImmediately = !isLayoutEditing.value
|
||||||
|
|
||||||
dashboardGridLayout.value = {}
|
dashboardGridLayout.value = {}
|
||||||
|
if (shouldPersistImmediately) {
|
||||||
await saveDashboardGridLayout({})
|
await saveDashboardGridLayout({})
|
||||||
|
}
|
||||||
|
isDashboardGridLayoutResetDraft.value = isLayoutEditing.value
|
||||||
dashboardGrid.value?.removeAll(false, false)
|
dashboardGrid.value?.removeAll(false, false)
|
||||||
isDashboardGridLayoutResetPending.value = true
|
|
||||||
await syncDashboardGrid()
|
await syncDashboardGrid()
|
||||||
if (isLayoutEditing.value) {
|
|
||||||
await exitDashboardLayoutEditing()
|
|
||||||
} else {
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
syncDashboardFillContentState()
|
syncDashboardFillContentState()
|
||||||
resizeAutoDashboardItemsToContent()
|
resizeAutoDashboardItemsToContent()
|
||||||
notifyDashboardContentResize()
|
notifyDashboardContentResize()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 appMode 底部动态按钮菜单,普通 Web 模式由页面内 FAB 承接。
|
// 生成 appMode 底部动态按钮菜单,普通 Web 模式由页面内 FAB 承接。
|
||||||
@@ -893,14 +908,14 @@ useDynamicButton({
|
|||||||
show: computed(() => appMode.value && route.path === '/dashboard'),
|
show: computed(() => appMode.value && route.path === '/dashboard'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换仪表板布局编辑模式,退出编辑时保存当前布局。
|
// 切换仪表板布局编辑模式,点击确认退出时持久化当前布局草稿。
|
||||||
function toggleDashboardLayoutEditing() {
|
function toggleDashboardLayoutEditing() {
|
||||||
if (isLayoutEditing.value) {
|
if (isLayoutEditing.value) {
|
||||||
void exitDashboardLayoutEditing()
|
void exitDashboardLayoutEditing()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isDashboardGridLayoutResetPending.value = false
|
isDashboardGridLayoutResetDraft.value = false
|
||||||
isLayoutEditing.value = true
|
isLayoutEditing.value = true
|
||||||
nextTick(syncDashboardGrid)
|
nextTick(syncDashboardGrid)
|
||||||
}
|
}
|
||||||
@@ -920,7 +935,10 @@ async function loadDashboardConfig() {
|
|||||||
// 设备档位配置同时承载 Grid 布局和显示项,显示项缺失时从旧版全局配置迁移。
|
// 设备档位配置同时承载 Grid 布局和显示项,显示项缺失时从旧版全局配置迁移。
|
||||||
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
|
const profileConfig = await loadDashboardProfileConfig(dashboardLayoutProfile.value)
|
||||||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
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)
|
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||||||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||||||
await saveDashboardProfileConfig()
|
await saveDashboardProfileConfig()
|
||||||
@@ -1286,12 +1304,12 @@ function handleDashboardGridResize() {
|
|||||||
notifyDashboardContentResize()
|
notifyDashboardContentResize()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。
|
// 缓存用户拖动后的位置,并保持未手动调高组件继续按内容自适应。
|
||||||
function handleDashboardGridDragStop() {
|
function handleDashboardGridDragStop() {
|
||||||
void persistCurrentDashboardGridLayout(false)
|
void cacheCurrentDashboardGridLayout(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存用户缩放后的布局,只有高度发生变化时才把高度标记为手动固定。
|
// 缓存用户缩放后的布局,只有高度发生变化时才把高度标记为手动固定。
|
||||||
function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLElement) {
|
function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLElement) {
|
||||||
const id = element.getAttribute('gs-id') ?? ''
|
const id = element.getAttribute('gs-id') ?? ''
|
||||||
const previousHeight = dashboardGridResizeStartHeights.get(id)
|
const previousHeight = dashboardGridResizeStartHeights.get(id)
|
||||||
@@ -1301,7 +1319,7 @@ function handleDashboardGridResizeStop(_event: Event, element: GridItemHTMLEleme
|
|||||||
dashboardGridResizeStartHeights.delete(id)
|
dashboardGridResizeStartHeights.delete(id)
|
||||||
isDashboardGridResizing.value = false
|
isDashboardGridResizing.value = false
|
||||||
notifyDashboardContentResize()
|
notifyDashboardContentResize()
|
||||||
void persistCurrentDashboardGridLayout(heightChanged ? id : false)
|
void cacheCurrentDashboardGridLayout(heightChanged ? id : false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并连续 resize 通知,模拟浏览器窗口变化让组件内部内容自适配新尺寸。
|
// 合并连续 resize 通知,模拟浏览器窗口变化让组件内部内容自适配新尺寸。
|
||||||
@@ -1314,9 +1332,9 @@ function notifyDashboardContentResize() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将 GridStack 保存结果归一化为本地布局覆盖表。
|
// 将 GridStack 保存结果归一化为临时布局草稿。
|
||||||
async function persistDashboardGridLayout(manualHeightId: string | false = false) {
|
function cacheDashboardGridLayoutDraft(manualHeightId: string | false = false) {
|
||||||
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return undefined
|
||||||
|
|
||||||
const gridColumns = getCurrentDashboardGridColumns()
|
const gridColumns = getCurrentDashboardGridColumns()
|
||||||
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
|
const savedWidgets = dashboardGrid.value.save(false, false, undefined, gridColumns)
|
||||||
@@ -1344,13 +1362,13 @@ async function persistDashboardGridLayout(manualHeightId: string | false = false
|
|||||||
|
|
||||||
isPersistingDashboardGridLayoutFromGrid.value = true
|
isPersistingDashboardGridLayoutFromGrid.value = true
|
||||||
dashboardGridLayout.value = nextLayout
|
dashboardGridLayout.value = nextLayout
|
||||||
const savePromise = saveDashboardGridLayout(nextLayout)
|
isDashboardGridLayoutResetDraft.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isPersistingDashboardGridLayoutFromGrid.value = false
|
isPersistingDashboardGridLayoutFromGrid.value = false
|
||||||
resizeAutoDashboardItemsToContent()
|
resizeAutoDashboardItemsToContent()
|
||||||
})
|
})
|
||||||
|
|
||||||
await savePromise
|
return nextLayout
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
|
// 根据组件 ID 查找默认宽度,保存布局时用于兜底。
|
||||||
@@ -1360,13 +1378,20 @@ function getDefaultDashboardGridWidthById(id: string, maxColumns = DASHBOARD_GRI
|
|||||||
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
|
return item ? Math.min(getDefaultDashboardGridWidth(item), maxColumns) : maxColumns
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待 GridStack 当前拖拽/缩放事件收尾后,保存用户当前看到的布局。
|
// 等待 GridStack 当前拖拽/缩放事件收尾后,把用户当前看到的布局写入临时草稿。
|
||||||
async function persistCurrentDashboardGridLayout(manualHeightId: string | false = false) {
|
async function cacheCurrentDashboardGridLayout(manualHeightId: string | false = false) {
|
||||||
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
if (!dashboardGrid.value || isSyncingDashboardGrid.value) return
|
||||||
|
|
||||||
isDashboardGridLayoutResetPending.value = false
|
|
||||||
await nextTick()
|
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 节点。
|
// 清理 GridStack 内部响应式布局缓存,并用当前 Vue 布局状态重新注册已有 DOM 节点。
|
||||||
@@ -1408,7 +1433,10 @@ watch(
|
|||||||
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
const legacyEnable = profileConfig?.enabled === undefined ? await loadLegacyDashboardEnableConfig() : undefined
|
||||||
if (profileSwitchId !== dashboardLayoutProfileSwitchId || dashboardLayoutProfile.value !== nextProfile) return
|
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)
|
enableConfig.value = mergeDashboardEnableConfig(profileConfig?.enabled ?? legacyEnable)
|
||||||
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
if (profileConfig?.enabled === undefined && legacyEnable !== undefined) {
|
||||||
await saveDashboardProfileConfig()
|
await saveDashboardProfileConfig()
|
||||||
|
|||||||
@@ -604,6 +604,11 @@ function getExistText(season: number) {
|
|||||||
else return t('media.status.inLibrary')
|
else return t('media.status.inLibrary')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 判断指定季集是否已存在于媒体服务器
|
||||||
|
function isEpisodeExists(season: number, episode: number) {
|
||||||
|
return existsEpisodes.value[season]?.includes(episode) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
// 计算订阅图标
|
// 计算订阅图标
|
||||||
const getSubscribeIcon = computed(() => {
|
const getSubscribeIcon = computed(() => {
|
||||||
if (mediaDetail.value.type === '电视剧') return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
|
if (mediaDetail.value.type === '电视剧') return subscribedSeasonNumbers.value.length > 0 ? 'mdi-heart' : 'mdi-heart-outline'
|
||||||
@@ -1036,7 +1041,14 @@ onUnmounted(() => {
|
|||||||
:key="episode.episode_number"
|
:key="episode.episode_number"
|
||||||
class="flex flex-col space-y-4 py-4 xl:flex-row xl:space-y-4 xl:space-x-4"
|
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">
|
<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>
|
<h3 class="text-lg">{{ episode.episode_number }} - {{ episode.name }}</h3>
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
@@ -1532,6 +1544,18 @@ a.crew-name {
|
|||||||
min-inline-size: 0;
|
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 {
|
.episode-group-label {
|
||||||
color: rgba(var(--v-theme-on-surface), 0.72);
|
color: rgba(var(--v-theme-on-surface), 0.72);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const props = defineProps({
|
|||||||
// 下载器/媒体服务器排序按需加载,降低系统设置页入口解析量。
|
// 下载器/媒体服务器排序按需加载,降低系统设置页入口解析量。
|
||||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||||
const LlmProviderAuthDialog = defineAsyncComponent(() => import('@/components/dialog/LlmProviderAuthDialog.vue'))
|
const LlmProviderAuthDialog = defineAsyncComponent(() => import('@/components/dialog/LlmProviderAuthDialog.vue'))
|
||||||
|
const AgentMcpSettingsDialog = defineAsyncComponent(() => import('@/components/dialog/AgentMcpSettingsDialog.vue'))
|
||||||
|
|
||||||
// 系统设置项
|
// 系统设置项
|
||||||
const SystemSettings = ref<any>({
|
const SystemSettings = ref<any>({
|
||||||
@@ -208,6 +209,9 @@ const advancedDialog = ref(false)
|
|||||||
const savingBasic = ref(false)
|
const savingBasic = ref(false)
|
||||||
const testingLlm = ref(false)
|
const testingLlm = ref(false)
|
||||||
const rustAccelAvailable = ref(false)
|
const rustAccelAvailable = ref(false)
|
||||||
|
const agentMcpDialog = ref(false)
|
||||||
|
const agentMcpServers = ref<AgentMcpServer[]>([])
|
||||||
|
const loadingAgentMcpServers = ref(false)
|
||||||
|
|
||||||
// 智能助手配置项较多,默认收起以降低基础设置页的视觉占用。
|
// 智能助手配置项较多,默认收起以降低基础设置页的视觉占用。
|
||||||
const aiAgentSettingsCollapsed = ref(true)
|
const aiAgentSettingsCollapsed = ref(true)
|
||||||
@@ -225,6 +229,24 @@ type LlmSettingsSnapshot = {
|
|||||||
LLM_TEMPERATURE: number
|
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 llmTestRequestId = 0
|
||||||
let llmTestAbortController: AbortController | null = null
|
let llmTestAbortController: AbortController | null = null
|
||||||
|
|
||||||
@@ -461,6 +483,8 @@ const selectedLlmModelInfo = computed(() => {
|
|||||||
source: selectedLlmModel.value.source || 'models.dev',
|
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 canTestLlm = computed(() => {
|
||||||
const snapshot = currentLlmSnapshot.value
|
const snapshot = currentLlmSnapshot.value
|
||||||
@@ -682,6 +706,23 @@ async function loadSystemSettings() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(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保存设置
|
// 调用API保存设置
|
||||||
@@ -1409,6 +1450,46 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
prepend-inner-icon="mdi-timer-outline"
|
prepend-inner-icon="mdi-timer-outline"
|
||||||
/>
|
/>
|
||||||
</VCol>
|
</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>
|
||||||
<VRow>
|
<VRow>
|
||||||
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="4">
|
||||||
@@ -1768,6 +1849,13 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
|
||||||
|
<AgentMcpSettingsDialog
|
||||||
|
v-if="agentMcpDialog"
|
||||||
|
v-model="agentMcpDialog"
|
||||||
|
:servers="agentMcpServers"
|
||||||
|
@saved="handleAgentMcpSaved"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 高级系统设置 -->
|
<!-- 高级系统设置 -->
|
||||||
<VDialog
|
<VDialog
|
||||||
v-if="advancedDialog"
|
v-if="advancedDialog"
|
||||||
@@ -2477,4 +2565,24 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
|
|||||||
.llm-test-trigger {
|
.llm-test-trigger {
|
||||||
min-inline-size: 0;
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user