feat: base image hash 改用依赖指纹,上游流式策略改为三态按钮

CI 和 deploy.sh 的 base image hash 计算从整文件 cat 改为
tomllib 提取 pyproject.toml 依赖指纹,避免仅改注释或工具配置
触发不必要的 base 重建;前端将上游流式策略从 Select 下拉改为
头部三态循环按钮(跟随请求/固定流式/固定非流),点击即保存。
This commit is contained in:
fawney19
2026-02-05 17:20:34 +08:00
parent 8ad53b9490
commit 5b9ba06af6
3 changed files with 183 additions and 58 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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