Compare commits
2 Commits
6b149a0dd0
...
22df28f2d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22df28f2d3 | ||
|
|
1ad6af3d5d |
@@ -9,11 +9,23 @@ provider_name = "gemini"
|
|||||||
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
|
整视频分析: 用 Files API 上传完整视频 -> generateContent 直出结构化 JSON
|
||||||
(本地不切片、不抽帧;Gemini 原生支持长视频)
|
(本地不切片、不抽帧;Gemini 原生支持长视频)
|
||||||
|
|
||||||
多 Key 轮换(2026-08-21 新增): 不同 Google Cloud 项目的 API Key 各自独立计费/配额,
|
多 Key 轮换(2026-08-21 新增,2026-08-22 改为真正均摊负载): 不同 Google Cloud
|
||||||
config 的 `api_key` 为主 Key,`extra_api_keys` 可以再配多个(各自项目的 Key)。
|
项目的 API Key 各自独立计费/配额,config 的 `api_key` 为主 Key,`extra_api_keys`
|
||||||
outer loop 按 Key 顺序尝试,inner loop 才是原有的模型 fallback 链——因为 Gemini
|
可以再配多个(各自项目的 Key)。outer loop 按 Key 顺序尝试,inner loop 才是原有
|
||||||
Files API 上传的文件只能被同一个 Key/项目引用,换 Key 必须重新上传,所以每个 Key
|
的模型 fallback 链——因为 Gemini Files API 上传的文件只能被同一个 Key/项目引用,
|
||||||
都要重走一遍"上传 -> 模型链尝试 -> 删除",不是简单地在同一次上传后换 key 调用。
|
换 Key 必须重新上传,所以每个 Key 都要重走一遍"上传 -> 模型链尝试 -> 删除"。
|
||||||
|
|
||||||
|
实测发现的问题: 原实现每次都从 api_keys[0] 开始试,只有 0 号 key 的所有模型全部
|
||||||
|
失败才会换下一个 key;但 flash-lite 兜底通常最终能成功,导致 0 号 key 几乎揽下
|
||||||
|
全部流量,其余 3 个 key 常年闲置——完全没有起到分摊配额的作用。现在改为
|
||||||
|
_rotated_keys():每次 analyze_video()/chat() 调用都从"上一次的下一个 key"开始
|
||||||
|
试起,调用结束(无论成败)就把起点往后挪一位,多次调用下来自然把请求均匀摊到
|
||||||
|
所有配置的 key 上,而不是"谁在前面谁扛所有流量"。
|
||||||
|
|
||||||
|
每次实际使用的 key 会以 "key{N}"(N 从 1 开始,对应 api_keys 里的原始下标)的
|
||||||
|
形式拼进 model_calls.model 字段(如 "gemini-flash-latest·key2"),这样现有的
|
||||||
|
按 provider+model 分组统计(fam-core ui_api.py 的 /api/ui/model-stats)不用改
|
||||||
|
schema 就能天然按 key 拆开显示,不需要新增字段/新迁移。
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@@ -50,6 +62,7 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
seen.add(resolved)
|
seen.add(resolved)
|
||||||
self.api_keys.append(resolved)
|
self.api_keys.append(resolved)
|
||||||
self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
|
self.api_key = self.api_keys[0] if self.api_keys else '' # 向后兼容单 key 用法
|
||||||
|
self._key_rotation_idx = 0 # 下一次调用从哪个 key 起手(轮转,均摊负载用)
|
||||||
self.timeout = config.get('timeout', 600)
|
self.timeout = config.get('timeout', 600)
|
||||||
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
|
# 模型级独立超时(最终值,不参与编排层 ×N 放大): {model_name: seconds}
|
||||||
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
|
# 例: {"gemini-flash-lite-latest": 90}(按实测耗时 ×4 配置)
|
||||||
@@ -68,6 +81,17 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
return os.environ.get(raw[2:-1], '')
|
return os.environ.get(raw[2:-1], '')
|
||||||
return raw
|
return raw
|
||||||
|
|
||||||
|
def _rotated_keys(self):
|
||||||
|
"""按当前轮转起点排序的 (原始下标从0开始, key) 列表;每调一次就把起点挪
|
||||||
|
到下一个 key,多次调用下来把流量均匀摊到全部配置的 key 上。"""
|
||||||
|
n = len(self.api_keys)
|
||||||
|
if n == 0:
|
||||||
|
return []
|
||||||
|
start = self._key_rotation_idx % n
|
||||||
|
order = list(range(start, n)) + list(range(0, start))
|
||||||
|
self._key_rotation_idx = (start + 1) % n
|
||||||
|
return [(i, self.api_keys[i]) for i in order]
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
if not self.api_key:
|
if not self.api_key:
|
||||||
logger.warning("Gemini API Key 未配置,健康检查失败")
|
logger.warning("Gemini API Key 未配置,健康检查失败")
|
||||||
@@ -108,41 +132,42 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
|
|
||||||
prompt = self._build_video_prompt(known_members_context, event_start_time)
|
prompt = self._build_video_prompt(known_members_context, event_start_time)
|
||||||
last_err = "no_key_available"
|
last_err = "no_key_available"
|
||||||
for idx, key in enumerate(self.api_keys):
|
for idx, key in self._rotated_keys():
|
||||||
|
key_label = f"key{idx + 1}"
|
||||||
file_uri, file_name = self._upload_file(video_path, key)
|
file_uri, file_name = self._upload_file(video_path, key)
|
||||||
if not file_uri:
|
if not file_uri:
|
||||||
self._delete_file(file_name, key) # 即使未等到 ACTIVE,也尽力清理
|
self._delete_file(file_name, key) # 即使未等到 ACTIVE,也尽力清理
|
||||||
last_err = f"key{idx}_upload_failed"
|
last_err = f"{key_label}_upload_failed"
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
# 3 秒密度 + person_appearances 特征使输出 JSON 较大,max_tokens 需足够高防截断
|
# 3 秒密度 + person_appearances 特征使输出 JSON 较大,max_tokens 需足够高防截断
|
||||||
text = self._generate_video(file_uri, prompt, key,
|
text = self._generate_video(file_uri, prompt, key, key_label,
|
||||||
max_tokens=16384, temperature=0.2)
|
max_tokens=16384, temperature=0.2)
|
||||||
if text is None:
|
if text is None:
|
||||||
last_err = f"key{idx}_all_models_failed"
|
last_err = f"{key_label}_all_models_failed"
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
result = parse_vlm_json(text)
|
result = parse_vlm_json(text)
|
||||||
result = self._normalize(result)
|
result = self._normalize(result)
|
||||||
if not result or 'events' not in result:
|
if not result or 'events' not in result:
|
||||||
logger.error(f"Gemini 视频输出缺少 events: {text[:150]}")
|
logger.error(f"Gemini 视频输出缺少 events: {text[:150]}")
|
||||||
last_err = f"key{idx}_missing_events"
|
last_err = f"{key_label}_missing_events"
|
||||||
continue
|
continue
|
||||||
result['compute_provider'] = 'gemini'
|
result['compute_provider'] = 'gemini'
|
||||||
self._cb.record_success()
|
self._cb.record_success()
|
||||||
logger.info(f"Gemini 整视频分析完成(key[{idx}]),"
|
logger.info(f"Gemini 整视频分析完成({key_label}),"
|
||||||
f"events={len(result.get('events', []))}")
|
f"events={len(result.get('events', []))}")
|
||||||
return result
|
return result
|
||||||
except VLMOutputInvalidError as e:
|
except VLMOutputInvalidError as e:
|
||||||
logger.error(f"Gemini 视频输出无法解析为 JSON: {e}")
|
logger.error(f"Gemini 视频输出无法解析为 JSON: {e}")
|
||||||
last_err = f"key{idx}_invalid_json"
|
last_err = f"{key_label}_invalid_json"
|
||||||
continue
|
continue
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logger.warning(f"Gemini key[{idx}] 视频分析超时 ({self.timeout}s)")
|
logger.warning(f"Gemini {key_label} 视频分析超时 ({self.timeout}s)")
|
||||||
last_err = f"key{idx}_timeout"
|
last_err = f"{key_label}_timeout"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini key[{idx}] 视频分析异常: {e}")
|
logger.error(f"Gemini {key_label} 视频分析异常: {e}")
|
||||||
last_err = f"key{idx}_exception"
|
last_err = f"{key_label}_exception"
|
||||||
finally:
|
finally:
|
||||||
self._delete_file(file_name, key)
|
self._delete_file(file_name, key)
|
||||||
self._cb.record_failure()
|
self._cb.record_failure()
|
||||||
@@ -250,7 +275,7 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _generate_video(self, file_uri: str, prompt: str, api_key: str,
|
def _generate_video(self, file_uri: str, prompt: str, api_key: str, key_label: str,
|
||||||
max_tokens: int, temperature: float) -> Optional[str]:
|
max_tokens: int, temperature: float) -> Optional[str]:
|
||||||
"""带模型 fallback 链的 generateContent(视频文件引用)调用。"""
|
"""带模型 fallback 链的 generateContent(视频文件引用)调用。"""
|
||||||
parts = [
|
parts = [
|
||||||
@@ -258,11 +283,12 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
{"text": prompt},
|
{"text": prompt},
|
||||||
]
|
]
|
||||||
for model in self.model_chain:
|
for model in self.model_chain:
|
||||||
|
stat_model = f"{model}·{key_label}" # 供 model_calls 按 key 拆分统计
|
||||||
# 模型级独立超时优先;未单独配置的用适配器默认(可能已被编排层 ×N 放大)
|
# 模型级独立超时优先;未单独配置的用适配器默认(可能已被编排层 ×N 放大)
|
||||||
model_timeout = self.model_timeouts.get(model, self.timeout)
|
model_timeout = self.model_timeouts.get(model, self.timeout)
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
logger.info(f"Gemini [{model}] 本轮请求超时 {model_timeout}s")
|
logger.info(f"Gemini [{stat_model}] 本轮请求超时 {model_timeout}s")
|
||||||
started = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
|
started = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
@@ -276,14 +302,14 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
)
|
)
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
duration = time.time() - t0
|
duration = time.time() - t0
|
||||||
self._emit_model_call(model, started, duration, False,
|
self._emit_model_call(stat_model, started, duration, False,
|
||||||
f"timeout({model_timeout}s)")
|
f"timeout({model_timeout}s)")
|
||||||
logger.warning(f"Gemini [{model}] 视频请求超时 ({model_timeout}s)")
|
logger.warning(f"Gemini [{stat_model}] 视频请求超时 ({model_timeout}s)")
|
||||||
break
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
duration = time.time() - t0
|
duration = time.time() - t0
|
||||||
self._emit_model_call(model, started, duration, False, str(e))
|
self._emit_model_call(stat_model, started, duration, False, str(e))
|
||||||
logger.error(f"Gemini [{model}] 视频请求异常: {e}")
|
logger.error(f"Gemini [{stat_model}] 视频请求异常: {e}")
|
||||||
break
|
break
|
||||||
duration = time.time() - t0
|
duration = time.time() - t0
|
||||||
|
|
||||||
@@ -294,29 +320,29 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
|
for p in (cands[0].get('content', {}) if cands else {}).get('parts', [])
|
||||||
).strip() if cands else ''
|
).strip() if cands else ''
|
||||||
if text:
|
if text:
|
||||||
self._emit_model_call(model, started, duration, True)
|
self._emit_model_call(stat_model, started, duration, True)
|
||||||
if model != self.model_name:
|
if model != self.model_name:
|
||||||
logger.info(f"Gemini 主模型不可用,由 fallback [{model}] 出结果")
|
logger.info(f"Gemini 主模型不可用,由 fallback [{model}] 出结果")
|
||||||
return text
|
return text
|
||||||
self._emit_model_call(model, started, duration, False, "empty_text")
|
self._emit_model_call(stat_model, started, duration, False, "empty_text")
|
||||||
logger.warning(f"Gemini [{model}] 返回空文本")
|
logger.warning(f"Gemini [{stat_model}] 返回空文本")
|
||||||
continue
|
continue
|
||||||
detail = resp.text[:150].replace('\n', ' ')
|
detail = resp.text[:150].replace('\n', ' ')
|
||||||
if resp.status_code == 429:
|
if resp.status_code == 429:
|
||||||
self._emit_model_call(model, started, duration, False, "429_quota")
|
self._emit_model_call(stat_model, started, duration, False, "429_quota")
|
||||||
logger.warning(f"Gemini [{model}] 429 配额耗尽,切换下一模型")
|
logger.warning(f"Gemini [{stat_model}] 429 配额耗尽,切换下一模型")
|
||||||
break
|
break
|
||||||
if resp.status_code == 503:
|
if resp.status_code == 503:
|
||||||
if attempt == 0:
|
if attempt == 0:
|
||||||
self._emit_model_call(model, started, duration, False, "503_overload_retry")
|
self._emit_model_call(stat_model, started, duration, False, "503_overload_retry")
|
||||||
logger.warning(f"Gemini [{model}] 503 过载,3s 后重试")
|
logger.warning(f"Gemini [{stat_model}] 503 过载,3s 后重试")
|
||||||
time.sleep(3)
|
time.sleep(3)
|
||||||
continue
|
continue
|
||||||
self._emit_model_call(model, started, duration, False, "503_overload")
|
self._emit_model_call(stat_model, started, duration, False, "503_overload")
|
||||||
break
|
break
|
||||||
self._emit_model_call(model, started, duration, False,
|
self._emit_model_call(stat_model, started, duration, False,
|
||||||
f"http_{resp.status_code}")
|
f"http_{resp.status_code}")
|
||||||
logger.warning(f"Gemini [{model}] HTTP {resp.status_code}: {detail}")
|
logger.warning(f"Gemini [{stat_model}] HTTP {resp.status_code}: {detail}")
|
||||||
break
|
break
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -383,8 +409,10 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
|
def _generate_text(self, text: str, max_tokens: int, temperature: float) -> Optional[str]:
|
||||||
"""纯文本 generateContent,按 key 轮换 × 模型 fallback 链依次尝试。"""
|
"""纯文本 generateContent,按 key 轮换(同 analyze_video 共用一套轮转起点)
|
||||||
for idx, api_key in enumerate(self.api_keys):
|
× 模型 fallback 链依次尝试。"""
|
||||||
|
for idx, api_key in self._rotated_keys():
|
||||||
|
key_label = f"key{idx + 1}"
|
||||||
for model in self.model_chain:
|
for model in self.model_chain:
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
@@ -396,10 +424,10 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
timeout=self.timeout
|
timeout=self.timeout
|
||||||
)
|
)
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logger.warning(f"Gemini key[{idx}] [{model}] 问答超时")
|
logger.warning(f"Gemini {key_label} [{model}] 问答超时")
|
||||||
continue
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini key[{idx}] [{model}] 问答异常: {e}")
|
logger.error(f"Gemini {key_label} [{model}] 问答异常: {e}")
|
||||||
continue
|
continue
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
cands = resp.json().get('candidates', [])
|
cands = resp.json().get('candidates', [])
|
||||||
@@ -410,7 +438,7 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
if out:
|
if out:
|
||||||
return out
|
return out
|
||||||
elif resp.status_code == 429:
|
elif resp.status_code == 429:
|
||||||
logger.warning(f"Gemini key[{idx}] [{model}] 429,切换下一模型/Key")
|
logger.warning(f"Gemini {key_label} [{model}] 429,切换下一模型/Key")
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -38,3 +38,33 @@ def test_no_keys_at_all():
|
|||||||
a = GeminiAdapter(_cfg(api_key=""))
|
a = GeminiAdapter(_cfg(api_key=""))
|
||||||
assert a.api_keys == []
|
assert a.api_keys == []
|
||||||
assert a.api_key == ""
|
assert a.api_key == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_keys_starts_at_zero_first_call():
|
||||||
|
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
|
||||||
|
order = a._rotated_keys()
|
||||||
|
assert [k for _, k in order] == ["key-primary", "key-2", "key-3", "key-4"]
|
||||||
|
assert [i for i, _ in order] == [0, 1, 2, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_keys_advances_each_call_evenly():
|
||||||
|
"""核心诉求: 连续调用应该轮流从不同 key 起手,而不是每次都从 0 号开始
|
||||||
|
(旧实现的 bug:0 号 key 的 fallback 模型通常最终能成功,导致其余 key 常年闲置)。"""
|
||||||
|
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
|
||||||
|
starts = [a._rotated_keys()[0][1] for _ in range(4)]
|
||||||
|
assert starts == ["key-primary", "key-2", "key-3", "key-4"]
|
||||||
|
# 转满一圈后应该回到起点
|
||||||
|
assert a._rotated_keys()[0][1] == "key-primary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_keys_wraps_around_correctly():
|
||||||
|
a = GeminiAdapter(_cfg(extra_api_keys=["key-2", "key-3", "key-4"]))
|
||||||
|
a._key_rotation_idx = 3 # 手动模拟"上次从 4 号 key 起手"
|
||||||
|
order = a._rotated_keys()
|
||||||
|
assert [k for _, k in order] == ["key-4", "key-primary", "key-2", "key-3"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotated_keys_single_key_never_errors():
|
||||||
|
a = GeminiAdapter(_cfg())
|
||||||
|
for _ in range(3):
|
||||||
|
assert a._rotated_keys() == [(0, "key-primary")]
|
||||||
|
|||||||
15
fam-ui/src/components/Spinner.vue
Normal file
15
fam-ui/src/components/Spinner.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
text: { type: String, default: '加载中…' },
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center justify-center gap-3 py-16 text-text-mute">
|
||||||
|
<svg class="h-6 w-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||||
|
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">{{ text }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -3,20 +3,25 @@ import { onMounted, ref, watch } from 'vue'
|
|||||||
import { api, fmtDateTime } from '../api.js'
|
import { api, fmtDateTime } from '../api.js'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import Spinner from '../components/Spinner.vue'
|
||||||
|
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const history = ref([])
|
const history = ref([])
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.chatHistory({ limit: pageSize, offset: page.value * pageSize })
|
const data = await api.chatHistory({ limit: pageSize, offset: page.value * pageSize })
|
||||||
history.value = data.history
|
history.value = data.history
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e.message
|
loadError.value = e.message
|
||||||
history.value = []
|
history.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,7 +32,8 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader icon="📝" title="对话历史" sub="历史问答记录" />
|
<PageHeader icon="📝" title="对话历史" sub="历史问答记录" />
|
||||||
|
|
||||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
<Spinner v-if="loading" text="加载对话历史…" />
|
||||||
|
<EmptyState v-else-if="loadError" icon="⚠" :text="loadError" />
|
||||||
<EmptyState v-else-if="!history.length" icon="💬" text="暂无对话记录" />
|
<EmptyState v-else-if="!history.length" icon="💬" text="暂无对话记录" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -3,16 +3,29 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { api, fmtDateTime } from '../api.js'
|
import { api, fmtDateTime } from '../api.js'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import Spinner from '../components/Spinner.vue'
|
||||||
|
import Badge from '../components/Badge.vue'
|
||||||
|
|
||||||
const aggregate = ref([])
|
const aggregate = ref([])
|
||||||
const calls = ref([])
|
const calls = ref([])
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// Gemini 适配器把实际用的 key 编号拼进 model 字段(如 "gemini-flash-latest·key2"),
|
||||||
|
// 这里拆出来单独显示成一个小徽章,而不是让用户在一长串字符串里自己找。
|
||||||
|
function splitKey(model) {
|
||||||
|
const idx = (model || '').lastIndexOf('·key')
|
||||||
|
if (idx === -1) return { model, key: '' }
|
||||||
|
return { model: model.slice(0, idx), key: model.slice(idx + 1) }
|
||||||
|
}
|
||||||
|
|
||||||
const aggRows = computed(() => aggregate.value.map(r => {
|
const aggRows = computed(() => aggregate.value.map(r => {
|
||||||
const total = (r.ok_cnt || 0) + (r.fail_cnt || 0)
|
const total = (r.ok_cnt || 0) + (r.fail_cnt || 0)
|
||||||
const rate = total ? ((r.ok_cnt || 0) / total * 100) : 0
|
const rate = total ? ((r.ok_cnt || 0) / total * 100) : 0
|
||||||
|
const { model, key } = splitKey(r.model)
|
||||||
return {
|
return {
|
||||||
model: `${r.provider} / ${r.model}`,
|
provider: r.provider,
|
||||||
|
model, key,
|
||||||
ok: r.ok_cnt || 0,
|
ok: r.ok_cnt || 0,
|
||||||
fail: r.fail_cnt || 0,
|
fail: r.fail_cnt || 0,
|
||||||
rate: rate.toFixed(1),
|
rate: rate.toFixed(1),
|
||||||
@@ -21,22 +34,28 @@ const aggRows = computed(() => aggregate.value.map(r => {
|
|||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const callRows = computed(() => calls.value.map(c => ({
|
const callRows = computed(() => calls.value.map(c => {
|
||||||
time: c.started_at ? fmtDateTime(c.started_at) : '—',
|
const { model, key } = splitKey(c.model)
|
||||||
model: `${c.provider} / ${c.model}`,
|
return {
|
||||||
duration: (c.duration_sec || 0).toFixed(1),
|
time: c.started_at ? fmtDateTime(c.started_at) : '—',
|
||||||
success: !!c.success,
|
provider: c.provider, model, key,
|
||||||
error: (c.error || '').slice(0, 60),
|
duration: (c.duration_sec || 0).toFixed(1),
|
||||||
filename: (c.filename || '').slice(0, 40),
|
success: !!c.success,
|
||||||
})))
|
error: (c.error || '').slice(0, 60),
|
||||||
|
filename: (c.filename || '').slice(0, 40),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.modelStats()
|
const data = await api.modelStats()
|
||||||
aggregate.value = data.aggregate
|
aggregate.value = data.aggregate
|
||||||
calls.value = data.recent_calls
|
calls.value = data.recent_calls
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e.message
|
loadError.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -44,6 +63,8 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader icon="🤖" title="云端模型统计" sub="请求时间 · 耗时 · 成功/失败 · 失败原因" />
|
<PageHeader icon="🤖" title="云端模型统计" sub="请求时间 · 耗时 · 成功/失败 · 失败原因" />
|
||||||
|
|
||||||
|
<Spinner v-if="loading" text="加载模型统计…" />
|
||||||
|
<template v-else>
|
||||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
|
||||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">按模型聚合</div>
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">按模型聚合</div>
|
||||||
@@ -53,6 +74,7 @@ onMounted(async () => {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||||
<th class="px-4 py-2 font-medium">模型</th>
|
<th class="px-4 py-2 font-medium">模型</th>
|
||||||
|
<th class="px-4 py-2 font-medium">Key</th>
|
||||||
<th class="px-4 py-2 font-medium">成功</th>
|
<th class="px-4 py-2 font-medium">成功</th>
|
||||||
<th class="px-4 py-2 font-medium">失败</th>
|
<th class="px-4 py-2 font-medium">失败</th>
|
||||||
<th class="px-4 py-2 font-medium">成功率%</th>
|
<th class="px-4 py-2 font-medium">成功率%</th>
|
||||||
@@ -62,7 +84,8 @@ onMounted(async () => {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(r, i) in aggRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
<tr v-for="(r, i) in aggRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||||
<td class="px-4 py-2 font-medium text-text">{{ r.model }}</td>
|
<td class="px-4 py-2 font-medium text-text">{{ r.provider }} / {{ r.model }}</td>
|
||||||
|
<td class="px-4 py-2"><Badge v-if="r.key" tone="violet">{{ r.key }}</Badge><span v-else class="text-text-faint">—</span></td>
|
||||||
<td class="px-4 py-2 text-ok">{{ r.ok }}</td>
|
<td class="px-4 py-2 text-ok">{{ r.ok }}</td>
|
||||||
<td class="px-4 py-2 text-danger">{{ r.fail }}</td>
|
<td class="px-4 py-2 text-danger">{{ r.fail }}</td>
|
||||||
<td class="px-4 py-2 font-mono tabular">{{ r.rate }}</td>
|
<td class="px-4 py-2 font-mono tabular">{{ r.rate }}</td>
|
||||||
@@ -81,6 +104,7 @@ onMounted(async () => {
|
|||||||
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
<tr class="bg-panel-3 text-left text-xs text-text-mute">
|
||||||
<th class="px-4 py-2 font-medium">请求时间</th>
|
<th class="px-4 py-2 font-medium">请求时间</th>
|
||||||
<th class="px-4 py-2 font-medium">模型</th>
|
<th class="px-4 py-2 font-medium">模型</th>
|
||||||
|
<th class="px-4 py-2 font-medium">Key</th>
|
||||||
<th class="px-4 py-2 font-medium">耗时s</th>
|
<th class="px-4 py-2 font-medium">耗时s</th>
|
||||||
<th class="px-4 py-2 font-medium">状态</th>
|
<th class="px-4 py-2 font-medium">状态</th>
|
||||||
<th class="px-4 py-2 font-medium">失败原因</th>
|
<th class="px-4 py-2 font-medium">失败原因</th>
|
||||||
@@ -90,7 +114,8 @@ onMounted(async () => {
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(c, i) in callRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
<tr v-for="(c, i) in callRows" :key="i" class="border-t border-border bg-panel-2 text-text-dim">
|
||||||
<td class="px-4 py-2 font-mono tabular">{{ c.time }}</td>
|
<td class="px-4 py-2 font-mono tabular">{{ c.time }}</td>
|
||||||
<td class="px-4 py-2">{{ c.model }}</td>
|
<td class="px-4 py-2">{{ c.provider }} / {{ c.model }}</td>
|
||||||
|
<td class="px-4 py-2"><Badge v-if="c.key" tone="violet">{{ c.key }}</Badge><span v-else class="text-text-faint">—</span></td>
|
||||||
<td class="px-4 py-2 font-mono tabular">{{ c.duration }}</td>
|
<td class="px-4 py-2 font-mono tabular">{{ c.duration }}</td>
|
||||||
<td class="px-4 py-2" :class="c.success ? 'text-ok' : 'text-danger'">{{ c.success ? '✅ 成功' : '❌ 失败' }}</td>
|
<td class="px-4 py-2" :class="c.success ? 'text-ok' : 'text-danger'">{{ c.success ? '✅ 成功' : '❌ 失败' }}</td>
|
||||||
<td class="px-4 py-2 text-text-faint">{{ c.error }}</td>
|
<td class="px-4 py-2 text-text-faint">{{ c.error }}</td>
|
||||||
@@ -101,7 +126,9 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-[11px] text-text-mute">
|
<p class="text-[11px] text-text-mute">
|
||||||
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。
|
统计来自甲骨文端每次云端模型请求的记录(经 30 分钟同步拉取到本地镜像)。Key 列只显示配置里第几个
|
||||||
|
Gemini Key 被用到(key1=主 key,key2/3/4=备用 key),不会显示密钥原文。
|
||||||
失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。
|
失败原因取值:429_quota(配额耗尽)/ timeout / 503_overload(过载重试)/ http_xxx / json_parse_failed 等。
|
||||||
</p>
|
</p>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,13 +4,18 @@ import { api } from '../api.js'
|
|||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
import PersonCard from '../components/PersonCard.vue'
|
import PersonCard from '../components/PersonCard.vue'
|
||||||
|
import Spinner from '../components/Spinner.vue'
|
||||||
|
|
||||||
const groups = ref([])
|
const groups = ref([])
|
||||||
const allLabels = ref([])
|
const allLabels = ref([])
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
// 命名/合并后也会调用 load() 刷新——这时列表已经有内容,不重新显示大 Spinner
|
||||||
|
// 闪掉整页(丢失滚动位置),只有首次加载(还没有任何数据)才转圈。
|
||||||
|
if (!groups.value.length) loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.people()
|
const data = await api.people()
|
||||||
groups.value = data.groups
|
groups.value = data.groups
|
||||||
@@ -18,6 +23,8 @@ async function load() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e.message
|
loadError.value = e.message
|
||||||
groups.value = []
|
groups.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +37,8 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader icon="👤" title="人物管理" sub="所有出现的人物 · 命名与合并(回推甲骨文)" />
|
<PageHeader icon="👤" title="人物管理" sub="所有出现的人物 · 命名与合并(回推甲骨文)" />
|
||||||
|
|
||||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
<Spinner v-if="loading && !groups.length" text="加载人物列表…" />
|
||||||
|
<EmptyState v-else-if="loadError" icon="⚠" :text="loadError" />
|
||||||
<EmptyState v-else-if="!groups.length" icon="👤" text="暂未发现任何人物(甲骨文尚未同步)" />
|
<EmptyState v-else-if="!groups.length" icon="👤" text="暂未发现任何人物(甲骨文尚未同步)" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import EmptyState from '../components/EmptyState.vue'
|
|||||||
import StatCard from '../components/StatCard.vue'
|
import StatCard from '../components/StatCard.vue'
|
||||||
import ServiceCard from '../components/ServiceCard.vue'
|
import ServiceCard from '../components/ServiceCard.vue'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
|
import Spinner from '../components/Spinner.vue'
|
||||||
|
|
||||||
const data = ref(null)
|
const data = ref(null)
|
||||||
const oracleError = ref('')
|
const oracleError = ref('')
|
||||||
@@ -52,10 +53,14 @@ const SVC_BADGE = {
|
|||||||
<PageHeader icon="🖥" title="服务状态" sub="各服务实时动态 · 最近 7 天活动记录" />
|
<PageHeader icon="🖥" title="服务状态" sub="各服务实时动态 · 最近 7 天活动记录" />
|
||||||
|
|
||||||
<div class="mb-5 flex items-center gap-3">
|
<div class="mb-5 flex items-center gap-3">
|
||||||
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">🔄 刷新</button>
|
<button :disabled="loading" @click="load" class="rounded-lg border border-border bg-panel-2 px-3.5 py-1.5 text-sm text-text-dim hover:border-accent/40 hover:text-white disabled:opacity-50">
|
||||||
|
{{ loading && data ? '刷新中…' : '🔄 刷新' }}
|
||||||
|
</button>
|
||||||
<span class="text-xs text-text-mute">点击刷新立即更新</span>
|
<span class="text-xs text-text-mute">点击刷新立即更新</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Spinner v-if="loading && !data" text="加载服务状态…" />
|
||||||
|
<template v-else>
|
||||||
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
|
<p v-if="oracleError" class="mb-4 text-sm text-warn">{{ oracleError }}</p>
|
||||||
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
|
<EmptyState v-if="!data?.oracle && !data?.nas_sync" text="暂时无法获取服务状态" />
|
||||||
|
|
||||||
@@ -112,4 +117,5 @@ const SVC_BADGE = {
|
|||||||
<p class="mt-2 text-xs text-text-mute">共 {{ activities.length }} 条记录(活动日志仅保留最近 7 天)</p>
|
<p class="mt-2 text-xs text-text-mute">共 {{ activities.length }} 条记录(活动日志仅保留最近 7 天)</p>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -3,17 +3,20 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
|
import { api, fmtDateOnly, fmtDateTime } from '../api.js'
|
||||||
import PageHeader from '../components/PageHeader.vue'
|
import PageHeader from '../components/PageHeader.vue'
|
||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
|
import Spinner from '../components/Spinner.vue'
|
||||||
|
|
||||||
const modelChart = ref([]) // [{label, value}]
|
const modelChart = ref([]) // [{label, value}]
|
||||||
const attention = ref([])
|
const attention = ref([])
|
||||||
const syncStatus = ref(null)
|
const syncStatus = ref(null)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
const BAR_COLORS = ['#5b8cff', '#8b6bff', '#3ddc9b', '#f5b84e', '#4fd1e8', '#ff7373']
|
const BAR_COLORS = ['#5b8cff', '#8b6bff', '#3ddc9b', '#f5b84e', '#4fd1e8', '#ff7373']
|
||||||
const maxVal = computed(() => Math.max(1, ...modelChart.value.map(m => m.value)))
|
const maxVal = computed(() => Math.max(1, ...modelChart.value.map(m => m.value)))
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [ms, att, statusData] = await Promise.all([
|
const [ms, att, statusData] = await Promise.all([
|
||||||
api.modelStats(),
|
api.modelStats(),
|
||||||
@@ -30,6 +33,8 @@ async function load() {
|
|||||||
syncStatus.value = statusData.sync
|
syncStatus.value = statusData.sync
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e.message
|
loadError.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,6 +44,8 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
|
<PageHeader icon="📈" title="统计图表" sub="模型来源 / 关注事件 / 同步状态" />
|
||||||
|
|
||||||
|
<Spinner v-if="loading" text="加载统计数据…" />
|
||||||
|
<template v-else>
|
||||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
||||||
|
|
||||||
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">模型来源分布</div>
|
<div class="mb-3 text-[15px] font-bold text-[#f7f9fc]">模型来源分布</div>
|
||||||
@@ -82,4 +89,5 @@ onMounted(load)
|
|||||||
<div v-if="syncStatus.last_error" class="font-semibold text-danger">⚠ {{ syncStatus.last_error }}</div>
|
<div v-if="syncStatus.last_error" class="font-semibold text-danger">⚠ {{ syncStatus.last_error }}</div>
|
||||||
</div>
|
</div>
|
||||||
<EmptyState v-else text="无法获取同步状态" />
|
<EmptyState v-else text="无法获取同步状态" />
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import StatCard from '../components/StatCard.vue'
|
|||||||
import EmptyState from '../components/EmptyState.vue'
|
import EmptyState from '../components/EmptyState.vue'
|
||||||
import EventItem from '../components/EventItem.vue'
|
import EventItem from '../components/EventItem.vue'
|
||||||
import Badge from '../components/Badge.vue'
|
import Badge from '../components/Badge.vue'
|
||||||
|
import Spinner from '../components/Spinner.vue'
|
||||||
|
|
||||||
const dateFilter = ref('')
|
const dateFilter = ref('')
|
||||||
const page = ref(0)
|
const page = ref(0)
|
||||||
@@ -14,6 +15,8 @@ const stats = ref(null)
|
|||||||
const selectedId = ref(null)
|
const selectedId = ref(null)
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
const videosLoading = ref(true)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
|
||||||
async function loadStats() {
|
async function loadStats() {
|
||||||
try {
|
try {
|
||||||
@@ -25,6 +28,7 @@ async function loadStats() {
|
|||||||
|
|
||||||
async function loadVideos() {
|
async function loadVideos() {
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
videosLoading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
|
const data = await api.videos({ date: dateFilter.value || undefined, page: page.value })
|
||||||
videos.value = data.videos
|
videos.value = data.videos
|
||||||
@@ -34,15 +38,20 @@ async function loadVideos() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
loadError.value = e.message
|
loadError.value = e.message
|
||||||
videos.value = []
|
videos.value = []
|
||||||
|
} finally {
|
||||||
|
videosLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(id) {
|
async function loadDetail(id) {
|
||||||
if (!id) { detail.value = null; return }
|
if (!id) { detail.value = null; return }
|
||||||
|
detailLoading.value = true
|
||||||
try {
|
try {
|
||||||
detail.value = await api.videoDetail(id)
|
detail.value = await api.videoDetail(id)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
detail.value = null
|
detail.value = null
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +106,8 @@ const modelBadges = computed(() => {
|
|||||||
<StatCard :value="stats.attention ?? 0" label="需关注" tone="warn" />
|
<StatCard :value="stats.attention ?? 0" label="需关注" tone="warn" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmptyState v-if="loadError" icon="⚠" :text="loadError" />
|
<Spinner v-if="videosLoading" text="加载视频会话…" />
|
||||||
|
<EmptyState v-else-if="loadError" icon="⚠" :text="loadError" />
|
||||||
<EmptyState v-else-if="!videos.length" icon="🗓" text="该日期暂无监控会话(甲骨文尚未同步数据?)" />
|
<EmptyState v-else-if="!videos.length" icon="🗓" text="该日期暂无监控会话(甲骨文尚未同步数据?)" />
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_2.35fr]">
|
<div v-else class="grid grid-cols-1 gap-8 lg:grid-cols-[1fr_2.35fr]">
|
||||||
@@ -120,7 +130,8 @@ const modelBadges = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="detail">
|
<Spinner v-if="detailLoading" text="加载会话详情…" />
|
||||||
|
<div v-else-if="detail">
|
||||||
<div class="mb-6 rounded-2xl border border-border-hi p-5 shadow-[var(--shadow-card)]"
|
<div class="mb-6 rounded-2xl border border-border-hi p-5 shadow-[var(--shadow-card)]"
|
||||||
style="background: linear-gradient(135deg, rgb(91 140 255 / .1), rgb(139 107 255 / .06));">
|
style="background: linear-gradient(135deg, rgb(91 140 255 / .1), rgb(139 107 255 / .06));">
|
||||||
<div class="flex flex-wrap items-center gap-2.5 text-lg font-bold text-[#f7f9fc]">
|
<div class="flex flex-wrap items-center gap-2.5 text-lg font-bold text-[#f7f9fc]">
|
||||||
|
|||||||
Reference in New Issue
Block a user