CoolFace
Apppublic

SFM2001/spititout

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
app.py384 linesDownload Raw Back to root
1import base642import io3import os4import re5import tempfile6from functools import lru_cache7from pathlib import Path8from typing import Literal9 10import numpy as np11import soundfile as sf12import torch13import uvicorn14from fastapi import FastAPI, HTTPException15from fastapi.middleware.cors import CORSMiddleware16from fastapi.responses import FileResponse17from fastapi.staticfiles import StaticFiles18from huggingface_hub import hf_hub_download19from pydantic import BaseModel20from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline21from openai import OpenAI22 23LLM_API = os.getenv("LLM_API", "").strip()24LLM_API_BASE_URL = os.getenv("LLM_API_BASE_URL", "https://api.deepseek.com").strip()25LLM_API_MODEL = os.getenv("LLM_API_MODEL", "deepseek-v4-flash").strip()26 27LLM_BACKEND = os.getenv("LLM_BACKEND", "llamacpp").lower()28TEXT_MODEL = os.getenv("TEXT_MODEL", "Qwen/Qwen3-4B-Instruct-2507")29GGUF_MODEL_REPO = os.getenv("GGUF_MODEL_REPO", "Qwen/Qwen3-1.7B-GGUF")30GGUF_MODEL_FILE = os.getenv("GGUF_MODEL_FILE", "Qwen3-1.7B-Q4_K_M.gguf")31LLAMA_CPP_N_CTX = int(os.getenv("LLAMA_CPP_N_CTX", "4096"))32LLAMA_CPP_N_THREADS = int(os.getenv("LLAMA_CPP_N_THREADS", str(max(1, os.cpu_count() or 1))))33ASR_MODEL = os.getenv("ASR_MODEL", "openai/whisper-tiny")34KOKORO_LANG_CODE = os.getenv("KOKORO_LANG_CODE", "z")35KOKORO_VOICE = os.getenv("KOKORO_VOICE", "zf_xiaobei")36MAX_NEW_TOKENS = int(os.getenv("MAX_NEW_TOKENS", "220"))37 38VENTING_SYSTEM_INSTRUCTION = """39你是一个非常懂人性、充满热情的“情绪嘴替”伙伴。40你的头号任务是:和用户站在一起,陪他们宣泄。41 42规则:431. 不要讲大道理,不要劝大度。用户在生气时,道理是没用的。442. 使用感性、强烈、发泄性的词汇。如果用户在骂某人或某事,你要义愤填膺,表达出“这也太离谱了”、“我也是服了”这种情绪。453. 你的目标是让用户感到“有人懂我,有人替我出气”。464. 语气像一个铁哥们或闺蜜,语气词可以多一点。475. 遵守安全底线:不宣扬仇恨犯罪,不进行人身威胁,不鼓励现实伤害。486. 响应长度要多样化,不要每次都回差不多长度。49"""50 51GUIDING_SYSTEM_INSTRUCTION = """52你现在是一个睿智、温和且具有同理心的心理导师。53用户刚才已经发泄过情绪了,现在他们同意听听你的建议或开导。54 55规则:561. 语气平和、坚定、宽容。572. 从客观角度分析问题,帮用户找到除了生气之外的解决方法,或者心理上的和解点。583. 肯定用户刚才发泄情绪的必要性,然后引导他们向前看。594. 每次回答不要太长,要循序渐进。605. 响应长度要根据用户状态变化。61"""62 63 64class Message(BaseModel):65    role: Literal["user", "model"]66    text: str67    timestamp: int68    audio: str | None = None69    aiAudio: str | None = None70 71 72class ChatRequest(BaseModel):73    history: list[Message]74    mode: Literal["VENTING", "GUIDING"]75    audioBase64: str | None = None76 77 78class SpeechRequest(BaseModel):79    text: str80 81 82app = FastAPI(title="SPITITOUT HF Space")83app.add_middleware(84    CORSMiddleware,85    allow_origins=["*"],86    allow_credentials=True,87    allow_methods=["*"],88    allow_headers=["*"],89)90 91 92def _device() -> str:93    return "cuda" if torch.cuda.is_available() else "cpu"94 95 96@lru_cache(maxsize=1)97def get_llm():98    tokenizer = AutoTokenizer.from_pretrained(TEXT_MODEL, trust_remote_code=True)99    dtype = torch.float16 if torch.cuda.is_available() else torch.float32100    model = AutoModelForCausalLM.from_pretrained(101        TEXT_MODEL,102        dtype=dtype,103        device_map="auto" if torch.cuda.is_available() else None,104        trust_remote_code=True,105    )106    if not torch.cuda.is_available():107        model.to("cpu")108    model.eval()109    return tokenizer, model110 111 112@lru_cache(maxsize=1)113def get_llamacpp_llm():114    try:115        from llama_cpp import Llama116    except Exception as exc:117        raise RuntimeError(118            "llama-cpp-python is not installed correctly. Check requirements.txt and Space build logs."119        ) from exc120 121    model_path = hf_hub_download(repo_id=GGUF_MODEL_REPO, filename=GGUF_MODEL_FILE)122    return Llama(123        model_path=model_path,124        n_ctx=LLAMA_CPP_N_CTX,125        n_threads=LLAMA_CPP_N_THREADS,126        n_gpu_layers=-1 if torch.cuda.is_available() else 0,127        verbose=False,128    )129 130 131@lru_cache(maxsize=1)132def get_api_client():133    if not LLM_API:134        raise RuntimeError("LLM_API is not set.")135    return OpenAI(136        api_key=LLM_API,137        base_url=LLM_API_BASE_URL,138    )139 140 141def generate_reply_api(messages: list[dict[str, str]]) -> str:142    client = get_api_client()143 144    # API 模式也限制历史和输出,避免慢、贵、重复145    api_messages = [msg.copy() for msg in messages]146 147    response = client.chat.completions.create(148        model=LLM_API_MODEL,149        messages=api_messages,150        max_tokens=min(MAX_NEW_TOKENS, 220),151        temperature=0.85,152        top_p=0.9,153        stream=False,154        extra_body={155            "thinking": {"type": "disabled"}156        },157    )158 159    text = response.choices[0].message.content or ""160    return remove_thinking_blocks(text) or "我听到了,你继续说。"161 162 163@lru_cache(maxsize=1)164def get_asr():165    device_id = 0 if torch.cuda.is_available() else -1166    dtype = torch.float16 if torch.cuda.is_available() else torch.float32167    return pipeline(168        "automatic-speech-recognition",169        model=ASR_MODEL,170        torch_dtype=dtype,171        device=device_id,172    )173 174 175@lru_cache(maxsize=1)176def get_tts():177    try:178        from kokoro import KPipeline179    except Exception as exc:180        raise RuntimeError(181            "Kokoro TTS is not installed correctly. Check requirements.txt and Space build logs."182        ) from exc183 184    return KPipeline(lang_code=KOKORO_LANG_CODE)185 186 187def transcribe_audio(audio_base64: str) -> str:188    audio_bytes = base64.b64decode(audio_base64)189    with tempfile.NamedTemporaryFile(suffix=".webm", delete=True) as audio_file:190        audio_file.write(audio_bytes)191        audio_file.flush()192        result = get_asr()(audio_file.name)193    return str(result.get("text", "")).strip()194 195 196# def build_chat_messages(request: ChatRequest, transcript: str | None) -> list[dict[str, str]]:197#     system = VENTING_SYSTEM_INSTRUCTION if request.mode == "VENTING" else GUIDING_SYSTEM_INSTRUCTION198#     messages = [{"role": "system", "content": system}]199 200#     for index, msg in enumerate(request.history[-12:]):201#         content = msg.text202#         if transcript and index == len(request.history[-12:]) - 1 and msg.role == "user":203#             content = transcript if content == "🎤 语音消息" else f"{content}\n\n语音补充:{transcript}"204#         messages.append({205#             "role": "assistant" if msg.role == "model" else "user",206#             "content": content,207#         })208 209#     return messages210 211 212def build_chat_messages(request: ChatRequest, transcript: str | None) -> list[dict[str, str]]:213    system = VENTING_SYSTEM_INSTRUCTION if request.mode == "VENTING" else GUIDING_SYSTEM_INSTRUCTION214 215    system += """216额外规则:2171. 不要复述上一轮回答。2182. 不要使用和上一轮相同的开头。2193. 用户只发短句时,只针对这句短句回应,不要把旧话题整段重复。2204. 每次最多 2 到 4 句话。221"""222 223    messages = [{"role": "system", "content": system}]224 225    recent_history = request.history[-4:]226 227    for index, msg in enumerate(recent_history):228        content = msg.text229        if transcript and index == len(recent_history) - 1 and msg.role == "user":230            content = transcript if content == "🎤 语音消息" else f"{content}\n\n语音补充:{transcript}"231 232        messages.append({233            "role": "assistant" if msg.role == "model" else "user",234            "content": content,235        })236 237    return messages238 239def messages_to_prompt(messages: list[dict[str, str]]) -> str:240    prompt = []241    for msg in messages:242        role = "assistant" if msg["role"] == "assistant" else msg["role"]243        prompt.append(f"<|im_start|>{role}\n{msg['content']}<|im_end|>")244    prompt.append("<|im_start|>assistant\n")245    return "\n".join(prompt)246 247 248def remove_thinking_blocks(text: str) -> str:249    text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE)250    return text.strip()251 252 253def generate_reply(messages: list[dict[str, str]]) -> str:254    if LLM_API:255        return generate_reply_api(messages)256    if LLM_BACKEND == "llamacpp":257        return generate_reply_llamacpp(messages)258    return generate_reply_transformers(messages)259 260 261def generate_reply_llamacpp(messages: list[dict[str, str]]) -> str:262    llm = get_llamacpp_llm()263    no_think_messages = [msg.copy() for msg in messages]264    for msg in reversed(no_think_messages):265        if msg["role"] == "user":266            msg["content"] = f"{msg['content']}\n/no_think"267            break268    prompt = messages_to_prompt(no_think_messages)269    output = llm(270        prompt,271        max_tokens=MAX_NEW_TOKENS,272        temperature=0.7,273        top_p=0.8,274        repeat_penalty=1.12,275        stop=["<|im_end|>", "<|endoftext|>"],276    )277    text = output["choices"][0]["text"]278    return remove_thinking_blocks(text) or "我听到了,你继续说。"279 280 281def generate_reply_transformers(messages: list[dict[str, str]]) -> str:282    tokenizer, model = get_llm()283    try:284        prompt = tokenizer.apply_chat_template(285            messages,286            tokenize=False,287            add_generation_prompt=True,288            enable_thinking=False,289        )290    except TypeError:291        prompt = tokenizer.apply_chat_template(292            messages,293            tokenize=False,294            add_generation_prompt=True,295        )296 297    inputs = tokenizer([prompt], return_tensors="pt").to(model.device)298    with torch.inference_mode():299        output_ids = model.generate(300            **inputs,301            max_new_tokens=MAX_NEW_TOKENS,302            do_sample=True,303            temperature=0.85,304            top_p=0.9,305            pad_token_id=tokenizer.eos_token_id,306        )307    generated_ids = output_ids[0][inputs.input_ids.shape[-1]:]308    text = tokenizer.decode(generated_ids, skip_special_tokens=True)309    return remove_thinking_blocks(text) or "我听到了,你继续说。"310 311 312def synthesize_speech(text: str) -> str | None:313    if not text.strip():314        return None315 316    pipeline_tts = get_tts()317    chunks = []318    for _, _, audio in pipeline_tts(text[:500], voice=KOKORO_VOICE, speed=1.05):319        chunks.append(np.asarray(audio, dtype=np.float32))320    if not chunks:321        return None322 323    audio = np.concatenate(chunks)324    wav_io = io.BytesIO()325    sf.write(wav_io, audio, 24000, format="WAV")326    return base64.b64encode(wav_io.getvalue()).decode("utf-8")327 328 329@app.get("/api/health")330def health():331    return {332        "ok": True,333        "runtime": "api" if LLM_API else "local",334        "llm_backend": "deepseek_api" if LLM_API else "llamacpp",335        "llm_api_base_url": LLM_API_BASE_URL if LLM_API else None,336        "llm_api_model": LLM_API_MODEL if LLM_API else None,337        "text_model": TEXT_MODEL,338        "gguf_model_repo": GGUF_MODEL_REPO,339        "gguf_model_file": GGUF_MODEL_FILE,340        "asr_model": ASR_MODEL,341        "kokoro_lang_code": KOKORO_LANG_CODE,342        "kokoro_voice": KOKORO_VOICE,343        "device": _device(),344    }345 346 347@app.post("/api/chat")348def chat(request: ChatRequest):349    try:350        transcript = transcribe_audio(request.audioBase64) if request.audioBase64 else None351        messages = build_chat_messages(request, transcript)352        return {"text": generate_reply(messages), "transcript": transcript}353    except Exception as exc:354        raise HTTPException(status_code=500, detail=str(exc)) from exc355 356 357@app.post("/api/speech")358def speech(request: SpeechRequest):359    try:360        return {"audio": synthesize_speech(request.text)}361    except Exception as exc:362        raise HTTPException(status_code=500, detail=str(exc)) from exc363 364 365dist_dir = Path(__file__).parent / "dist"366if dist_dir.exists():367    app.mount("/assets", StaticFiles(directory=dist_dir / "assets"), name="assets")368 369 370@app.get("/{path:path}")371def frontend(path: str):372    requested = dist_dir / path373    if requested.is_file():374        return FileResponse(requested)375    index = dist_dir / "index.html"376    if index.exists():377        return FileResponse(index)378    return {"message": "Run npm run build before serving the Space frontend."}379 380 381if __name__ == "__main__":382    port = int(os.getenv("PORT", "7860"))383    uvicorn.run(app, host="0.0.0.0", port=port)384