mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: base image hash 改用依赖指纹,上游流式策略改为三态按钮
CI 和 deploy.sh 的 base image hash 计算从整文件 cat 改为 tomllib 提取 pyproject.toml 依赖指纹,避免仅改注释或工具配置 触发不必要的 base 重建;前端将上游流式策略从 Select 下拉改为 头部三态循环按钮(跟随请求/固定流式/固定非流),点击即保存。
This commit is contained in:
75
.github/workflows/docker-publish.yml
vendored
75
.github/workflows/docker-publish.yml
vendored
@@ -15,12 +15,17 @@ env:
|
|||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
BASE_IMAGE_NAME: fawney19/aether-base
|
BASE_IMAGE_NAME: fawney19/aether-base
|
||||||
APP_IMAGE_NAME: fawney19/aether
|
APP_IMAGE_NAME: fawney19/aether
|
||||||
# Files that affect base image - used for hash calculation
|
# Base image hash inputs:
|
||||||
BASE_FILES: "Dockerfile.base pyproject.toml frontend/package.json frontend/package-lock.json"
|
# - Dockerfile.base
|
||||||
|
# - pyproject.toml (dependency fingerprint only; ignores tool/optional deps)
|
||||||
|
# - frontend/package-lock.json
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-base-changes:
|
check-base-changes:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: read
|
||||||
outputs:
|
outputs:
|
||||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||||
steps:
|
steps:
|
||||||
@@ -41,15 +46,45 @@ jobs:
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Calculate current hash of base-related files
|
# Calculate current hash of base-related inputs (dependency-only fingerprint)
|
||||||
CURRENT_HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
PY_FINGERPRINT=$(python3 - <<'PY'
|
||||||
echo "Current base files hash: $CURRENT_HASH"
|
import json
|
||||||
|
import pathlib
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||||
|
project = data.get("project") or {}
|
||||||
|
build = data.get("build-system") or {}
|
||||||
|
|
||||||
|
fingerprint = {
|
||||||
|
"requires-python": project.get("requires-python"),
|
||||||
|
"dependencies": sorted(project.get("dependencies") or []),
|
||||||
|
"build-backend": build.get("build-backend"),
|
||||||
|
"build-requires": sorted(build.get("requires") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
CURRENT_HASH=$(
|
||||||
|
(
|
||||||
|
cat Dockerfile.base
|
||||||
|
printf '%s\n' "$PY_FINGERPRINT"
|
||||||
|
cat frontend/package-lock.json
|
||||||
|
) | sha256sum | cut -d' ' -f1
|
||||||
|
)
|
||||||
|
echo "Current base hash: $CURRENT_HASH"
|
||||||
|
|
||||||
# Try to get hash label from remote image config
|
# Try to get hash label from remote image config
|
||||||
# Pull the image config and extract labels
|
# Pull the image config and extract labels
|
||||||
REMOTE_HASH=""
|
REMOTE_HASH=""
|
||||||
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest 2>/dev/null; then
|
if docker pull ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest; then
|
||||||
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
REMOTE_HASH=$(docker inspect ${{ env.REGISTRY }}/${{ env.BASE_IMAGE_NAME }}:latest --format '{{ index .Config.Labels "org.opencontainers.image.base.hash" }}' 2>/dev/null) || true
|
||||||
|
else
|
||||||
|
echo "WARN: failed to pull remote base image; forcing base rebuild."
|
||||||
|
echo "base_changed=true" >> $GITHUB_OUTPUT
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
if [ -z "$REMOTE_HASH" ] || [ "$REMOTE_HASH" == "<no value>" ]; then
|
||||||
@@ -87,7 +122,33 @@ jobs:
|
|||||||
- name: Calculate base files hash
|
- name: Calculate base files hash
|
||||||
id: hash
|
id: hash
|
||||||
run: |
|
run: |
|
||||||
HASH=$(cat ${{ env.BASE_FILES }} 2>/dev/null | sha256sum | cut -d' ' -f1)
|
PY_FINGERPRINT=$(python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||||
|
project = data.get("project") or {}
|
||||||
|
build = data.get("build-system") or {}
|
||||||
|
|
||||||
|
fingerprint = {
|
||||||
|
"requires-python": project.get("requires-python"),
|
||||||
|
"dependencies": sorted(project.get("dependencies") or []),
|
||||||
|
"build-backend": build.get("build-backend"),
|
||||||
|
"build-requires": sorted(build.get("requires") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
|
||||||
|
HASH=$(
|
||||||
|
(
|
||||||
|
cat Dockerfile.base
|
||||||
|
printf '%s\n' "$PY_FINGERPRINT"
|
||||||
|
cat frontend/package-lock.json
|
||||||
|
) | sha256sum | cut -d' ' -f1
|
||||||
|
)
|
||||||
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
echo "hash=$HASH" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Extract metadata for base image
|
- name: Extract metadata for base image
|
||||||
|
|||||||
30
deploy.sh
30
deploy.sh
@@ -21,9 +21,37 @@ HASH_FILE=".deps-hash"
|
|||||||
CODE_HASH_FILE=".code-hash"
|
CODE_HASH_FILE=".code-hash"
|
||||||
MIGRATION_HASH_FILE=".migration-hash"
|
MIGRATION_HASH_FILE=".migration-hash"
|
||||||
|
|
||||||
|
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致):
|
||||||
|
# - [build-system] requires / build-backend
|
||||||
|
# - [project] requires-python / dependencies
|
||||||
|
# 使用 Python tomllib 解析,不受 TOML 格式变化影响。
|
||||||
|
pyproject_deps_fingerprint() {
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json, pathlib, tomllib
|
||||||
|
|
||||||
|
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||||
|
project = data.get("project") or {}
|
||||||
|
build = data.get("build-system") or {}
|
||||||
|
|
||||||
|
fingerprint = {
|
||||||
|
"requires-python": project.get("requires-python"),
|
||||||
|
"dependencies": sorted(project.get("dependencies") or []),
|
||||||
|
"build-backend": build.get("build-backend"),
|
||||||
|
"build-requires": sorted(build.get("requires") or []),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||||
calc_deps_hash() {
|
calc_deps_hash() {
|
||||||
cat pyproject.toml frontend/package.json frontend/package-lock.json Dockerfile.base.local 2>/dev/null | md5sum | cut -d' ' -f1
|
{
|
||||||
|
cat Dockerfile.base.local 2>/dev/null
|
||||||
|
pyproject_deps_fingerprint
|
||||||
|
# 前端依赖以 lock 为准(避免仅改 scripts/version 触发 base 重建)
|
||||||
|
cat frontend/package-lock.json 2>/dev/null
|
||||||
|
} | md5sum | cut -d' ' -f1
|
||||||
}
|
}
|
||||||
|
|
||||||
# 计算代码文件的哈希值(包含 Dockerfile.app.local)
|
# 计算代码文件的哈希值(包含 Dockerfile.app.local)
|
||||||
|
|||||||
@@ -51,6 +51,17 @@
|
|||||||
<Shuffle class="w-3.5 h-3.5" />
|
<Shuffle class="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 上游流式三态按钮 -->
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
:class="getUpstreamStreamButtonClass(endpoint)"
|
||||||
|
:title="getUpstreamStreamTooltip(endpoint)"
|
||||||
|
:disabled="savingEndpointId === endpoint.id"
|
||||||
|
@click="handleCycleUpstreamStream(endpoint)"
|
||||||
|
>
|
||||||
|
<Radio class="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
<!-- 启用/停用 -->
|
<!-- 启用/停用 -->
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -81,7 +92,7 @@
|
|||||||
<div class="p-4 space-y-4">
|
<div class="p-4 space-y-4">
|
||||||
<!-- URL 配置区 -->
|
<!-- URL 配置区 -->
|
||||||
<div class="flex items-end gap-3">
|
<div class="flex items-end gap-3">
|
||||||
<div class="flex-1 min-w-0 grid grid-cols-4 gap-3">
|
<div class="flex-1 min-w-0 grid grid-cols-3 gap-3">
|
||||||
<div class="col-span-2 space-y-1.5">
|
<div class="col-span-2 space-y-1.5">
|
||||||
<Label class="text-xs text-muted-foreground">Base URL</Label>
|
<Label class="text-xs text-muted-foreground">Base URL</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -100,28 +111,6 @@
|
|||||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label class="text-xs text-muted-foreground">上游流式</Label>
|
|
||||||
<Select
|
|
||||||
:model-value="getEndpointEditState(endpoint.id)?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)"
|
|
||||||
@update:model-value="(v) => updateEndpointStreamPolicy(endpoint.id, v as string)"
|
|
||||||
>
|
|
||||||
<SelectTrigger class="h-9 text-xs">
|
|
||||||
<SelectValue placeholder="跟随客户端" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="auto">
|
|
||||||
跟随客户端
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="force_stream">
|
|
||||||
强制流式
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="force_non_stream">
|
|
||||||
强制非流式
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
<!-- 保存/撤销按钮(URL/路径有修改时显示) -->
|
||||||
<div
|
<div
|
||||||
@@ -505,7 +494,7 @@ import {
|
|||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw } from 'lucide-vue-next'
|
import { Settings, Trash2, Check, X, Power, ChevronRight, Plus, Shuffle, RotateCcw, Radio } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||||
@@ -780,18 +769,6 @@ function updateEndpointField(endpointId: string, field: 'url' | 'path', value: s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateEndpointStreamPolicy(endpointId: string, value: string) {
|
|
||||||
if (!endpointEditStates.value[endpointId]) {
|
|
||||||
const endpoint = localEndpoints.value.find(e => e.id === endpointId)
|
|
||||||
if (endpoint) {
|
|
||||||
endpointEditStates.value[endpointId] = initEndpointEditState(endpoint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (endpointEditStates.value[endpointId]) {
|
|
||||||
endpointEditStates.value[endpointId].upstreamStreamPolicy = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取端点的编辑规则
|
// 获取端点的编辑规则
|
||||||
function getEndpointEditRules(endpointId: string): EditableRule[] {
|
function getEndpointEditRules(endpointId: string): EditableRule[] {
|
||||||
const state = endpointEditStates.value[endpointId]
|
const state = endpointEditStates.value[endpointId]
|
||||||
@@ -1167,7 +1144,7 @@ function hasUrlChanges(endpoint: ProviderEndpoint): boolean {
|
|||||||
if (!state) return false
|
if (!state) return false
|
||||||
if (state.url !== endpoint.base_url) return true
|
if (state.url !== endpoint.base_url) return true
|
||||||
if (state.path !== (endpoint.custom_path || '')) return true
|
if (state.path !== (endpoint.custom_path || '')) return true
|
||||||
if (state.upstreamStreamPolicy !== getEndpointUpstreamStreamPolicy(endpoint)) return true
|
// 注:upstreamStreamPolicy 现在由头部按钮直接保存,无需在此检查
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,19 +1302,7 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
|||||||
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
|
if (hasRulesChanges(endpoint)) payload.header_rules = rulesToHeaderRules(state.rules)
|
||||||
if (hasBodyRulesChanges(endpoint)) payload.body_rules = rulesToBodyRules(state.bodyRules)
|
if (hasBodyRulesChanges(endpoint)) payload.body_rules = rulesToBodyRules(state.bodyRules)
|
||||||
|
|
||||||
// endpoint.config.upstream_stream_policy
|
// 注:upstreamStreamPolicy 现在由头部按钮直接保存,不在此处处理
|
||||||
if (state.upstreamStreamPolicy !== getEndpointUpstreamStreamPolicy(endpoint)) {
|
|
||||||
const merged: Record<string, any> = { ...(endpoint.config || {}) }
|
|
||||||
// Normalize config keys: keep only canonical `upstream_stream_policy`
|
|
||||||
delete merged.upstream_stream_policy
|
|
||||||
delete merged.upstreamStreamPolicy
|
|
||||||
delete merged.upstream_stream
|
|
||||||
|
|
||||||
if (state.upstreamStreamPolicy !== 'auto') {
|
|
||||||
merged.upstream_stream_policy = state.upstreamStreamPolicy
|
|
||||||
}
|
|
||||||
payload.config = Object.keys(merged).length > 0 ? merged : null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(payload).length === 0) return
|
if (Object.keys(payload).length === 0) return
|
||||||
|
|
||||||
@@ -1370,6 +1335,77 @@ async function handleToggleFormatConversion(endpoint: ProviderEndpoint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取上游流式按钮的当前状态(优先使用编辑状态)
|
||||||
|
function getCurrentUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
||||||
|
const state = endpointEditStates.value[endpoint.id]
|
||||||
|
return state?.upstreamStreamPolicy ?? getEndpointUpstreamStreamPolicy(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取上游流式按钮的样式类
|
||||||
|
function getUpstreamStreamButtonClass(endpoint: ProviderEndpoint): string {
|
||||||
|
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||||
|
const base = 'h-7 w-7'
|
||||||
|
if (policy === 'force_stream') return `${base} text-primary`
|
||||||
|
if (policy === 'force_non_stream') return `${base} text-destructive`
|
||||||
|
return `${base} text-muted-foreground` // auto - 跟随请求,淡色显示
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取上游流式按钮的提示文字
|
||||||
|
function getUpstreamStreamTooltip(endpoint: ProviderEndpoint): string {
|
||||||
|
const policy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||||
|
if (policy === 'force_stream') return '固定流式(点击切换为固定非流)'
|
||||||
|
if (policy === 'force_non_stream') return '固定非流(点击切换为跟随请求)'
|
||||||
|
return '跟随请求(点击切换为固定流式)'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环切换上游流式策略并直接保存
|
||||||
|
async function handleCycleUpstreamStream(endpoint: ProviderEndpoint) {
|
||||||
|
const currentPolicy = getCurrentUpstreamStreamPolicy(endpoint)
|
||||||
|
let nextPolicy: string
|
||||||
|
let nextLabel: string
|
||||||
|
|
||||||
|
// 循环:auto -> force_stream -> force_non_stream -> auto
|
||||||
|
if (currentPolicy === 'auto') {
|
||||||
|
nextPolicy = 'force_stream'
|
||||||
|
nextLabel = '固定流式'
|
||||||
|
} else if (currentPolicy === 'force_stream') {
|
||||||
|
nextPolicy = 'force_non_stream'
|
||||||
|
nextLabel = '固定非流'
|
||||||
|
} else {
|
||||||
|
nextPolicy = 'auto'
|
||||||
|
nextLabel = '跟随请求'
|
||||||
|
}
|
||||||
|
|
||||||
|
savingEndpointId.value = endpoint.id
|
||||||
|
try {
|
||||||
|
const merged: Record<string, any> = { ...(endpoint.config || {}) }
|
||||||
|
// 清理旧的 key
|
||||||
|
delete merged.upstream_stream_policy
|
||||||
|
delete merged.upstreamStreamPolicy
|
||||||
|
delete merged.upstream_stream
|
||||||
|
|
||||||
|
if (nextPolicy !== 'auto') {
|
||||||
|
merged.upstream_stream_policy = nextPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateEndpoint(endpoint.id, {
|
||||||
|
config: Object.keys(merged).length > 0 ? merged : null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 更新本地编辑状态
|
||||||
|
if (endpointEditStates.value[endpoint.id]) {
|
||||||
|
endpointEditStates.value[endpoint.id].upstreamStreamPolicy = nextPolicy
|
||||||
|
}
|
||||||
|
|
||||||
|
success(`已切换为${nextLabel}`)
|
||||||
|
emit('endpointUpdated')
|
||||||
|
} catch (error: any) {
|
||||||
|
showError(error.response?.data?.detail || '操作失败', '错误')
|
||||||
|
} finally {
|
||||||
|
savingEndpointId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加端点
|
// 添加端点
|
||||||
async function handleAddEndpoint() {
|
async function handleAddEndpoint() {
|
||||||
if (!props.provider || !newEndpoint.value.api_format) return
|
if (!props.provider || !newEndpoint.value.api_format) return
|
||||||
|
|||||||
Reference in New Issue
Block a user