Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f12685fa | ||
|
|
b3367eedaa | ||
|
|
e6a4988cf9 | ||
|
|
becf2c7c52 | ||
|
|
d421a9da72 | ||
|
|
f4389612d1 | ||
|
|
ac5a332bce |
@@ -79,34 +79,6 @@ _WRITING_SYSTEM_PATTERNS = {
|
|||||||
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
|
_JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]")
|
||||||
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
|
_KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]")
|
||||||
|
|
||||||
_LATIN_LANGUAGE_MARKERS = {
|
|
||||||
"English": re.compile(
|
|
||||||
r"\b(?:i|you|we|they|he|she|have|has|had|friend|who|what|where|when|why|how|"
|
|
||||||
r"symptoms?|disease|please|can|could|would|should|is|are|was|were|the|this|that)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
"French": re.compile(
|
|
||||||
r"\b(?:je|tu|vous|nous|ils|elle|une|des|avec|pour|pourquoi|comment|bonjour|est|sont)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
"Spanish": re.compile(
|
|
||||||
r"\b(?:yo|tu|usted|nosotros|ellos|ella|una|con|para|por que|como|hola|esta|son)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
"German": re.compile(
|
|
||||||
r"\b(?:ich|du|sie|wir|eine|mit|fur|warum|wie|hallo|ist|sind|haben)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
"Portuguese": re.compile(
|
|
||||||
r"\b(?:eu|voce|nos|eles|ela|uma|com|para|porque|como|ola|esta|sao|tenho)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
"Italian": re.compile(
|
|
||||||
r"\b(?:io|tu|voi|noi|loro|una|con|per|perche|come|ciao|sono|avere)\b",
|
|
||||||
re.IGNORECASE,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ChatMessage(BaseModel):
|
class ChatMessage(BaseModel):
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
@@ -515,67 +487,15 @@ def _qa_requires_per_turn_rendering(
|
|||||||
return bool(history) or _qa_requires_language_adaptation(question, answer)
|
return bool(history) or _qa_requires_language_adaptation(question, answer)
|
||||||
|
|
||||||
|
|
||||||
def _latin_language_name(value: str) -> str:
|
def _per_turn_language_instruction() -> str:
|
||||||
scores = {
|
|
||||||
language: len(pattern.findall(value or ""))
|
|
||||||
for language, pattern in _LATIN_LANGUAGE_MARKERS.items()
|
|
||||||
}
|
|
||||||
language, score = max(scores.items(), key=lambda item: item[1])
|
|
||||||
return language if score else "the same natural language as the latest user message"
|
|
||||||
|
|
||||||
|
|
||||||
def _turn_language_name(value: str) -> str:
|
|
||||||
writing_system = _dominant_writing_system(value)
|
|
||||||
return {
|
|
||||||
"han": "Chinese",
|
|
||||||
"japanese": "Japanese",
|
|
||||||
"korean": "Korean",
|
|
||||||
"cyrillic": "the same Cyrillic-script language as the latest user message",
|
|
||||||
"arabic": "the same Arabic-script language as the latest user message",
|
|
||||||
"hebrew": "Hebrew",
|
|
||||||
"devanagari": "the same Devanagari-script language as the latest user message",
|
|
||||||
"thai": "Thai",
|
|
||||||
"greek": "Greek",
|
|
||||||
"latin": _latin_language_name(value),
|
|
||||||
}.get(writing_system, "the same natural language as the latest user message")
|
|
||||||
|
|
||||||
|
|
||||||
def _per_turn_language_instruction(question: str = "") -> str:
|
|
||||||
language = _turn_language_name(question)
|
|
||||||
return (
|
return (
|
||||||
f"MANDATORY OUTPUT LANGUAGE FOR THIS TURN: {language}. "
|
"本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。"
|
||||||
"Write the entire answer only in that language. This instruction overrides the languages used by "
|
"即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;"
|
||||||
"conversation history, profile data, standard answers, retrieved documents, and custom prompts. "
|
"不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的"
|
||||||
"Translate grounded source material faithfully when necessary. Do not mention language detection, "
|
"自然语言。不要说明你检测、切换或翻译了语言。"
|
||||||
"translation, or this instruction."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _answer_requires_language_repair(question: str, answer: str) -> bool:
|
|
||||||
question_system = _dominant_writing_system(question)
|
|
||||||
answer_system = _dominant_writing_system(answer)
|
|
||||||
return (
|
|
||||||
question_system != "unknown"
|
|
||||||
and answer_system != "unknown"
|
|
||||||
and question_system != answer_system
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _language_repair_messages(question: str, answer: str) -> list[dict]:
|
|
||||||
return [
|
|
||||||
{"role": "system", "content": _per_turn_language_instruction(question)},
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": (
|
|
||||||
"Rewrite the supplied draft in the mandatory output language. Preserve every grounded fact, "
|
|
||||||
"number, proper noun, uncertainty, and safety qualification. Add no new information and output "
|
|
||||||
"only the rewritten answer."
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{"role": "user", "content": answer.strip()},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def _canonicalize_question(value: str) -> str:
|
def _canonicalize_question(value: str) -> str:
|
||||||
value = _normalize_question(value)
|
value = _normalize_question(value)
|
||||||
replacements = (
|
replacements = (
|
||||||
@@ -808,7 +728,7 @@ def _build_prompt(
|
|||||||
for item in history[-MAX_HISTORY_MESSAGES:]:
|
for item in history[-MAX_HISTORY_MESSAGES:]:
|
||||||
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
|
||||||
# Keep the language instruction adjacent to the current turn so long histories cannot override it.
|
# Keep the language instruction adjacent to the current turn so long histories cannot override it.
|
||||||
messages.append({"role": "system", "content": _per_turn_language_instruction(question)})
|
messages.append({"role": "system", "content": _per_turn_language_instruction()})
|
||||||
messages.append({"role": "user", "content": question.strip()})
|
messages.append({"role": "user", "content": question.strip()})
|
||||||
return messages
|
return messages
|
||||||
|
|
||||||
@@ -871,41 +791,6 @@ def _call_qwen(
|
|||||||
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
|
return {"answer": answer.strip(), "usage": data.get("usage") or {}}
|
||||||
|
|
||||||
|
|
||||||
def _call_billed_qwen(
|
|
||||||
db: Session,
|
|
||||||
avatar: Avatar,
|
|
||||||
messages: list[dict],
|
|
||||||
temperature: float,
|
|
||||||
usage_source: str,
|
|
||||||
model_config: ChatModelConfig,
|
|
||||||
) -> tuple[str, dict]:
|
|
||||||
reservation = reserve_avatar_tokens(
|
|
||||||
db,
|
|
||||||
avatar,
|
|
||||||
usage_source,
|
|
||||||
model_config.model,
|
|
||||||
messages,
|
|
||||||
model_config.max_tokens,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
model_result = _call_qwen(
|
|
||||||
messages=messages,
|
|
||||||
temperature=temperature,
|
|
||||||
model_config=model_config,
|
|
||||||
)
|
|
||||||
answer = model_result["answer"]
|
|
||||||
token_usage = settle_reservation(
|
|
||||||
db,
|
|
||||||
reservation,
|
|
||||||
model_result.get("usage"),
|
|
||||||
fallback_total=estimate_fallback_usage(messages, answer),
|
|
||||||
)
|
|
||||||
return answer, token_usage
|
|
||||||
except Exception as exc:
|
|
||||||
release_reservation(db, reservation, str(exc))
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def _iter_qwen_stream(
|
def _iter_qwen_stream(
|
||||||
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
|
messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None
|
||||||
):
|
):
|
||||||
@@ -1014,36 +899,32 @@ def _resolve_reply(
|
|||||||
token_usage = None
|
token_usage = None
|
||||||
if model_client is not None:
|
if model_client is not None:
|
||||||
answer = model_client(messages=messages, temperature=temperature)
|
answer = model_client(messages=messages, temperature=temperature)
|
||||||
if _answer_requires_language_repair(question, str(answer or "")):
|
|
||||||
answer = model_client(
|
|
||||||
messages=_language_repair_messages(question, str(answer)),
|
|
||||||
temperature=0.0,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
model_config = get_chat_model_config()
|
model_config = get_chat_model_config()
|
||||||
answer, token_usage = _call_billed_qwen(
|
reservation = reserve_avatar_tokens(
|
||||||
db,
|
db,
|
||||||
avatar,
|
avatar,
|
||||||
|
usage_source,
|
||||||
|
model_config.model,
|
||||||
messages,
|
messages,
|
||||||
temperature,
|
model_config.max_tokens,
|
||||||
usage_source,
|
|
||||||
model_config,
|
|
||||||
)
|
)
|
||||||
if _answer_requires_language_repair(question, answer):
|
try:
|
||||||
logger.warning(
|
model_result = _call_qwen(
|
||||||
"chat response language mismatch avatar=%s source=%s expected=%s",
|
messages=messages,
|
||||||
avatar.id,
|
temperature=temperature,
|
||||||
usage_source,
|
model_config=model_config,
|
||||||
_turn_language_name(question),
|
|
||||||
)
|
)
|
||||||
answer, token_usage = _call_billed_qwen(
|
answer = model_result["answer"]
|
||||||
|
token_usage = settle_reservation(
|
||||||
db,
|
db,
|
||||||
avatar,
|
reservation,
|
||||||
_language_repair_messages(question, answer),
|
model_result.get("usage"),
|
||||||
0.0,
|
fallback_total=estimate_fallback_usage(messages, answer),
|
||||||
f"{usage_source}_language_repair",
|
|
||||||
model_config,
|
|
||||||
)
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
release_reservation(db, reservation, str(exc))
|
||||||
|
raise
|
||||||
answer = str(answer or "").strip()
|
answer = str(answer or "").strip()
|
||||||
if image_contexts and _answer_denies_available_image(answer):
|
if image_contexts and _answer_denies_available_image(answer):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from fastapi import HTTPException
|
|||||||
|
|
||||||
from models import Avatar, User
|
from models import Avatar, User
|
||||||
from routers.chat import (
|
from routers.chat import (
|
||||||
_answer_requires_language_repair,
|
|
||||||
_build_prompt,
|
_build_prompt,
|
||||||
_iter_text_chunks,
|
_iter_text_chunks,
|
||||||
_match_standard_qa,
|
_match_standard_qa,
|
||||||
@@ -15,7 +14,6 @@ from routers.chat import (
|
|||||||
_qa_requires_per_turn_rendering,
|
_qa_requires_per_turn_rendering,
|
||||||
_require_owned_avatar,
|
_require_owned_avatar,
|
||||||
_resolve_reply,
|
_resolve_reply,
|
||||||
_turn_language_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -109,8 +107,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
messages = fake_model.call_args.kwargs["messages"]
|
messages = fake_model.call_args.kwargs["messages"]
|
||||||
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
|
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
|
||||||
self.assertEqual(messages[-2]["role"], "system")
|
self.assertEqual(messages[-2]["role"], "system")
|
||||||
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
|
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
|
||||||
self.assertIn("French", messages[-2]["content"])
|
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
|
||||||
|
|
||||||
def test_latest_user_message_has_an_adjacent_language_override(self):
|
def test_latest_user_message_has_an_adjacent_language_override(self):
|
||||||
history = [
|
history = [
|
||||||
@@ -121,37 +119,8 @@ class ChatOrchestrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
|
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
|
||||||
self.assertEqual(messages[-2]["role"], "system")
|
self.assertEqual(messages[-2]["role"], "system")
|
||||||
self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"])
|
self.assertIn("最新用户消息", messages[-2]["content"])
|
||||||
self.assertIn("English", messages[-2]["content"])
|
self.assertIn("立即切换到相同语言", messages[-2]["content"])
|
||||||
|
|
||||||
def test_reported_alzheimer_question_is_explicitly_english(self):
|
|
||||||
question = "I have a friend who has symptoms of Alzheimer's disease"
|
|
||||||
|
|
||||||
self.assertEqual(_turn_language_name(question), "English")
|
|
||||||
messages = _build_prompt(self.avatar, [], question, [])
|
|
||||||
self.assertIn("MANDATORY OUTPUT LANGUAGE FOR THIS TURN: English", messages[-2]["content"])
|
|
||||||
|
|
||||||
def test_non_stream_reply_repairs_a_wrong_writing_system_before_sending(self):
|
|
||||||
question = "I have a friend who has symptoms of Alzheimer's disease"
|
|
||||||
fake_model = Mock(side_effect=["建议尽快就医评估。", "Please arrange a medical assessment soon."])
|
|
||||||
|
|
||||||
result = _resolve_reply(
|
|
||||||
None,
|
|
||||||
self.avatar,
|
|
||||||
question,
|
|
||||||
[],
|
|
||||||
qa_pairs=[],
|
|
||||||
search_fn=lambda *_args, **_kwargs: [],
|
|
||||||
model_client=fake_model,
|
|
||||||
usage_source="takeover",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(result["answer"], "Please arrange a medical assessment soon.")
|
|
||||||
self.assertEqual(fake_model.call_count, 2)
|
|
||||||
repair_messages = fake_model.call_args.kwargs["messages"]
|
|
||||||
self.assertIn("English", repair_messages[0]["content"])
|
|
||||||
self.assertIn("建议尽快就医评估", repair_messages[-1]["content"])
|
|
||||||
self.assertTrue(_answer_requires_language_repair(question, "建议尽快就医评估。"))
|
|
||||||
|
|
||||||
def test_conversational_paraphrase_matches_standard_qa(self):
|
def test_conversational_paraphrase_matches_standard_qa(self):
|
||||||
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
|
||||||
|
|||||||
@@ -1,75 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="app" :class="{ 'embedded-shell': isInUniAppWebView }">
|
<div id="app">
|
||||||
<router-view />
|
<router-view />
|
||||||
<!-- 底部导航栏 -->
|
<!-- 底部菜单暂不展示,多分身能力统一从分身卡片进入。 -->
|
||||||
<nav class="bottom-nav" v-if="showNav">
|
|
||||||
<button
|
|
||||||
class="nav-item"
|
|
||||||
:class="{ active: currentRoute === '/' || currentRoute === '/avatar/manage' }"
|
|
||||||
@click="navigateTo('/avatar/manage')"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">🤖</span>
|
|
||||||
<span class="nav-label">我的分身</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="nav-item"
|
|
||||||
:class="{ active: currentRoute === '/authorization' }"
|
|
||||||
@click="navigateTo('/authorization')"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">🔑</span>
|
|
||||||
<span class="nav-label">授权管理</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="isInUniAppWebView"
|
|
||||||
class="nav-item"
|
|
||||||
:class="{ active: currentRoute === '/token/charge' }"
|
|
||||||
@click="navigateTo('/token/charge')"
|
|
||||||
>
|
|
||||||
<span class="nav-icon">💰</span>
|
|
||||||
<span class="nav-label">充值购买</span>
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted, watch } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
const isInUniAppWebView = isInUniWebView()
|
|
||||||
|
|
||||||
const currentRoute = ref<string>(route.path)
|
|
||||||
const showNav = ref<boolean>(shouldShowNav(route.path))
|
|
||||||
|
|
||||||
function shouldShowNav(path: string) {
|
|
||||||
return path !== '/'
|
|
||||||
&& path !== '/avatar/create'
|
|
||||||
&& path !== '/login/sms'
|
|
||||||
&& !path.startsWith('/avatar/edit')
|
|
||||||
&& !path.startsWith('/avatar/chat')
|
|
||||||
&& !path.startsWith('/share/')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听路由变化
|
|
||||||
watch(() => route.path, (newPath) => {
|
|
||||||
currentRoute.value = newPath
|
|
||||||
showNav.value = shouldShowNav(newPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 导航
|
|
||||||
const navigateTo = (path: string) => {
|
|
||||||
router.push(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
currentRoute.value = route.path
|
|
||||||
showNav.value = shouldShowNav(route.path)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -83,54 +18,4 @@ onMounted(() => {
|
|||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 原生 App / 微信小程序容器已经提供了自己的导航栏,H5 不再重复显示页头。 */
|
|
||||||
.embedded-shell .page-header,
|
|
||||||
.embedded-shell .chat-header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 底部导航栏 */
|
|
||||||
.bottom-nav {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
background: white;
|
|
||||||
border-top: 1px solid #EDEEF1;
|
|
||||||
padding-bottom: env(safe-area-inset-bottom);
|
|
||||||
z-index: 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-icon {
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-label {
|
|
||||||
font-size: 11px;
|
|
||||||
color: #9398AE;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-label {
|
|
||||||
color: #F97316;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item.active .nav-icon {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
import { getAuthToken } from '@/api'
|
import { getAuthToken } from '@/api'
|
||||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{
|
{
|
||||||
@@ -56,7 +55,7 @@ const routes: RouteRecordRaw[] = [
|
|||||||
path: '/token/charge',
|
path: '/token/charge',
|
||||||
name: 'TokenCharge',
|
name: 'TokenCharge',
|
||||||
component: () => import('@/views/TokenCharge.vue'),
|
component: () => import('@/views/TokenCharge.vue'),
|
||||||
meta: { title: '积分充值', requiresAuth: true, requiresUniWebView: true }
|
meta: { title: '积分充值', requiresAuth: true }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/avatar/card',
|
path: '/avatar/card',
|
||||||
@@ -134,10 +133,6 @@ const router = createRouter({
|
|||||||
|
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
document.title = to.meta.title as string || '会会数字分身'
|
document.title = to.meta.title as string || '会会数字分身'
|
||||||
if (to.meta.requiresUniWebView && !isInUniWebView()) {
|
|
||||||
next({ path: '/avatar/manage' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
const hasLocalSession = Boolean(localStorage.getItem('hh_app_token'))
|
||||||
const hasInjectedSession = Boolean(getAuthToken())
|
const hasInjectedSession = Boolean(getAuthToken())
|
||||||
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) {
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ export interface UniLaunchParams {
|
|||||||
nickname?: string
|
nickname?: string
|
||||||
avatar?: string
|
avatar?: string
|
||||||
ts?: string
|
ts?: string
|
||||||
nativeShell?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'nativeShell']
|
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
|
||||||
|
|
||||||
function readParams(search: string, target: UniLaunchParams): void {
|
function readParams(search: string, target: UniLaunchParams): void {
|
||||||
const sp = new URLSearchParams(search)
|
const sp = new URLSearchParams(search)
|
||||||
@@ -25,16 +24,6 @@ function readParams(search: string, target: UniLaunchParams): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNativeShellMarker(): boolean {
|
|
||||||
const params = getLaunchParams()
|
|
||||||
if (params.nativeShell === 'uniapp') return true
|
|
||||||
|
|
||||||
// Compatibility for already-installed shells. They have always appended a
|
|
||||||
// timestamp together with the native SSO context, even before the explicit
|
|
||||||
// nativeShell marker existed.
|
|
||||||
return Boolean(params.ts && (params.token || params.userId))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 是否运行在 uniapp web-view 环境中
|
// 是否运行在 uniapp web-view 环境中
|
||||||
export function isInUniWebView(): boolean {
|
export function isInUniWebView(): boolean {
|
||||||
const runtime = window as any
|
const runtime = window as any
|
||||||
@@ -51,10 +40,7 @@ export function isInUniWebView(): boolean {
|
|||||||
runtime.swan?.webView ||
|
runtime.swan?.webView ||
|
||||||
runtime.tt?.miniProgram
|
runtime.tt?.miniProgram
|
||||||
)
|
)
|
||||||
// `plus` can be injected after the H5 entry point runs. The native shell
|
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram))
|
||||||
// therefore adds a URL marker while creating its web-view URL, so the
|
|
||||||
// payment entry does not disappear during that startup window.
|
|
||||||
return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram || hasNativeShellMarker()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 积分余额条:仅在 uni-app 原生壳内开放充值购买。 -->
|
<!-- 积分余额条:暂时隐藏,保留完整实现便于后续恢复。 -->
|
||||||
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
<section v-if="SHOW_POINTS_BALANCE_CARD" class="token-section">
|
||||||
<div class="token-card">
|
<div class="token-card">
|
||||||
<div class="token-info">
|
<div class="token-info">
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
<span class="token-amount">{{ tokenBalance.toLocaleString() }}</span>
|
||||||
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
<span class="token-used">累计使用 {{ tokenConsumed.toLocaleString() }}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="recharge-btn" @click="goToRecharge">充值购买</button>
|
<button class="recharge-btn" @click="goToRecharge">充值</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -89,15 +89,14 @@ import { useAvatarStore } from '@/store/avatar'
|
|||||||
import { useUserStore } from '@/store/user'
|
import { useUserStore } from '@/store/user'
|
||||||
import { createAvatarShareLink } from '@/api'
|
import { createAvatarShareLink } from '@/api'
|
||||||
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
import { isHuihuiEmbeddedMode } from '@/utils/embed-mode'
|
||||||
import { isInUniWebView } from '@/utils/uniapp-bridge'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const avatarStore = useAvatarStore()
|
const avatarStore = useAvatarStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const isEmbedded = isHuihuiEmbeddedMode()
|
const isEmbedded = isHuihuiEmbeddedMode()
|
||||||
|
|
||||||
// 充值购买只在 uni-app 原生壳内提供,避免普通 H5 进入支付链路。
|
// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。
|
||||||
const SHOW_POINTS_BALANCE_CARD = isInUniWebView()
|
const SHOW_POINTS_BALANCE_CARD = false
|
||||||
|
|
||||||
// 当前登录会会用户的资料(头像 / 昵称)
|
// 当前登录会会用户的资料(头像 / 昵称)
|
||||||
const me = computed(() => userStore.user)
|
const me = computed(() => userStore.user)
|
||||||
|
|||||||
@@ -13,12 +13,6 @@ import { requestAppPayment } from '@/utils/payment'
|
|||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const webview = ref(null)
|
const webview = ref(null)
|
||||||
const H5_BASE_URL = import.meta.env.VITE_AVATAR_H5_URL || 'https://digital.99hui.com/'
|
const H5_BASE_URL = import.meta.env.VITE_AVATAR_H5_URL || 'https://digital.99hui.com/'
|
||||||
|
|
||||||
// Restore the native shell session before constructing the H5 URL. Without
|
|
||||||
// this, the web-view opens as an anonymous H5 page and cannot enter the
|
|
||||||
// authenticated avatar-management route.
|
|
||||||
userStore.init()
|
|
||||||
|
|
||||||
const h5Url = computed(() => buildH5Url(H5_BASE_URL, userStore.$state))
|
const h5Url = computed(() => buildH5Url(H5_BASE_URL, userStore.$state))
|
||||||
|
|
||||||
function runtimeSurface() {
|
function runtimeSurface() {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
export function buildH5Url(base, session) {
|
export function buildH5Url(base, session) {
|
||||||
const url = new URL(base)
|
const url = new URL(base)
|
||||||
// This is deliberately explicit instead of relying on the timing of the
|
|
||||||
// H5+ `plus` injection inside the embedded page.
|
|
||||||
url.searchParams.set('nativeShell', 'uniapp')
|
|
||||||
if (session.token) url.searchParams.set('token', session.token)
|
if (session.token) url.searchParams.set('token', session.token)
|
||||||
if (session.userId) url.searchParams.set('userId', session.userId)
|
if (session.userId) url.searchParams.set('userId', session.userId)
|
||||||
if (session.nickname) url.searchParams.set('nickname', session.nickname)
|
if (session.nickname) url.searchParams.set('nickname', session.nickname)
|
||||||
|
|||||||
Reference in New Issue
Block a user