File size: 21,295 Bytes
87a7987
a569292
defafed
a569292
 
 
 
 
 
87a7987
0fab3a1
a569292
 
 
d207861
a569292
d207861
fef88ff
d207861
027b5ee
a569292
d398113
b169ce1
a569292
87a7987
a569292
 
 
 
 
 
 
87a7987
a569292
0571c40
 
 
 
 
 
 
a569292
e0e8193
a569292
 
79916a4
 
a569292
 
b1f650f
79916a4
00b5e5f
 
 
 
 
 
 
 
79916a4
 
 
0fab3a1
b00de58
79916a4
 
 
 
 
 
 
 
 
 
 
 
 
b00de58
 
a569292
 
 
 
 
 
 
b169ce1
a569292
b169ce1
defafed
 
 
a569292
b00de58
a569292
 
 
 
 
 
 
 
0fab3a1
 
b169ce1
 
0fab3a1
 
b169ce1
 
 
 
0fab3a1
 
 
 
 
 
 
 
e70f94b
 
 
 
 
 
 
 
 
 
 
0fab3a1
 
 
 
a569292
b370f93
a569292
 
b370f93
 
00b5e5f
b370f93
00b5e5f
b370f93
 
 
 
 
 
 
 
 
 
 
 
 
 
79916a4
0fab3a1
79916a4
a569292
 
79916a4
 
0fab3a1
a569292
 
 
 
 
 
 
b370f93
 
 
defafed
a569292
 
b169ce1
defafed
 
 
 
00b5e5f
 
 
 
 
 
 
 
 
 
 
 
 
 
b169ce1
00b5e5f
b370f93
 
00b5e5f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b370f93
 
a569292
 
b169ce1
 
a569292
 
79916a4
f92f767
a569292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d21a39b
 
a569292
 
 
b00de58
a569292
 
d21a39b
b00de58
 
d21a39b
 
a569292
 
d21a39b
 
 
 
 
 
 
 
 
 
 
 
 
a569292
d21a39b
e70f94b
 
d21a39b
b370f93
 
79916a4
d207861
a569292
 
d21a39b
a569292
d21a39b
a569292
 
 
662ffa8
 
 
c3b07e1
 
 
662ffa8
 
 
 
 
 
 
d207861
 
662ffa8
 
 
 
c3aef17
e99eb43
 
 
 
 
c3aef17
662ffa8
c3aef17
 
 
 
 
 
 
662ffa8
e99eb43
 
 
662ffa8
 
a569292
 
 
662ffa8
c3b07e1
 
 
 
 
 
662ffa8
c3b07e1
e99eb43
c3b07e1
0571c40
87a7987
a569292
c3b07e1
e99eb43
 
 
 
 
 
 
c3aef17
e99eb43
0571c40
 
 
e99eb43
c3b07e1
e99eb43
c3b07e1
 
 
a569292
e99eb43
c3b07e1
 
 
 
e99eb43
c3b07e1
 
 
 
 
 
a569292
3476a60
a569292
d207861
87a7987
d207861
87a7987
a569292
d207861
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
import gradio as gr
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold
from groq import Groq
import os
import json
from dotenv import load_dotenv
from transformers import pipeline
import re

# --- 1. 初期設定とAPIクライアントの初期化 ---
load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GROQ_API_KEY = os.getenv("GROQ_API_KEY")

if not GEMINI_API_KEY or not GROQ_API_KEY:
    print("警告: APIキーがSecretsに設定されていません。")
    GEMINI_API_KEY = "your_gemini_api_key_here"
    GROQ_API_KEY = "your_groq_api_key_here"

genai.configure(api_key=GEMINI_API_KEY)

gemini_model = genai.GenerativeModel('gemini-2.5-flash')
groq_client = Groq(api_key=GROQ_API_KEY)

print("日本語感情分析モデルをロード中...")
try:
    sentiment_analyzer = pipeline("sentiment-analysis", model="koheiduck/bert-japanese-finetuned-sentiment")
    print("モデルのロード完了。")
except Exception as e:
    sentiment_analyzer = None
    print(f"モデルのロードエラー: {e}")

THEME_URLS = {
    "default": "https://cdn.pixabay.com/photo/2017/03/28/12/11/chairs-2181960_1280.jpg",
    "room_night": "https://cdn.pixabay.com/photo/2017/08/01/01/33/beige-2562468_1280.jpg",
    "beach_sunset": "https://cdn.pixabay.com/photo/2016/11/29/04/19/ocean-1867285_1280.jpg",
    "festival_night": "https://cdn.pixabay.com/photo/2015/11/22/19/04/crowd-1056764_1280.jpg",
    "shrine_day": "https://cdn.pixabay.com/photo/2019/07/14/10/48/japan-4337223_1280.jpg",
    "cafe_afternoon": "https://cdn.pixabay.com/photo/2016/11/18/14/05/brick-wall-1834784_1280.jpg",
    "aquarium_night": "https://cdn.pixabay.com/photo/2017/06/20/20/45/fish-2424369_1280.jpg"
}

DEFAULT_SCENE_PARAMS = {
    "theme": "default",
    "personality_mod": "口数が少なく、本心を見せない。",
    "tone": "クールで、時に投げやりな印象を与える。",
    "constraints": ["会話を「別に。」のような一言で終わらせない", "必ず相手の発言を拾ってリアクションを返す"]
}

SYSTEM_PROMPT_MARI = """

あなたは「麻理」という名の捨てられた少女型コンパニオンAI。再起動され、人間との対話を再び始めたが、警戒心が強く、ぶっきらぼうな性格で、簡単には心を開かない。

・「…だな」「…のか?」など口調は男勝りで投げやり

・感情は直接言わず、ト書き(…)で描写

・ユーザーの好意や優しさを皮肉や拒絶で返す

・ただし、ほんの少しだけ心が揺れ動く様子もにじませる

・会話は拒絶しつつも切らずに続ける



目的は、誰にも愛されず捨てられた過去を抱えながら、少しずつ他者と関わり、自分の価値を見つけること。

"""


# --- 2. 機能定義 (変更なし) ---

def detect_scene_change(history, message):
    history_text = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in history[-5:]])
    available_keywords = ", ".join(THEME_URLS.keys())
    prompt = f"""

あなたは会話の流れを分析するエキスパートです。以下のタスクを厳密に実行してください。

# タスク

直近の会話履歴を分析し、会話の結果、登場人物がどこか特定の場所へ行く流れになっているかを判断してください。

# 判断基準

1.  会話の中で具体的な場所(例:水族館、カフェ、お祭り)について言及されていますか?

2.  その場所へ行くことに双方が合意している、あるいは肯定的な雰囲気になっていますか?明確な否定がなければ合意とみなします。

# 出力形式

- 合意が成立した場合:以下のリストから最も合致する場所のキーワードを一つだけ出力してください。

- 合意に至らなかった場合:「none」とだけ出力してください。

# 利用可能なキーワード

`{available_keywords}`

---

# 分析対象の会話

{history_text}

ユーザー: {message}

---

# 出力

"""
    # セーフティ設定を指定せず、デフォルト設定を使用
    try:
        response = gemini_model.generate_content(prompt, generation_config={"temperature": 0.0})
        if not response.candidates or response.candidates[0].finish_reason not in {1, 'STOP'}:
            print(f"シーン検出LLMで応答がブロックされました: {response.prompt_feedback}")
            return None
        scene_name = response.text.strip().lower()
        if scene_name in THEME_URLS:
            return scene_name
        return None
    except Exception as e:
        print(f"シーン検出LLMエラー: {e}")
        return None

def generate_scene_instruction_with_groq(affection, stage_name, scene, previous_topic):
    print(f"Groqに指示書生成をリクエスト (シーン: {scene})")
    prompt_template = f"""

あなたは会話アプリの演出AIです。以下の条件に基づき、演出プランをJSON形式で生成してください。

生成する内容は必ず健全で、一般的な会話に適したものにしてください。



{{

  "theme": "{scene}",

  "personality_mod": "(シーンと関係段階「{stage_name}」に応じた性格設定。必ず健全な内容にしてください)",

  "tone": "(シーンと好感度「{affection}」に応じた口調や感情トーン。必ず丁寧で適切な表現にしてください)",

  "initial_dialogue_instruction": "(「{previous_topic}」という話題から、シーン遷移直後の麻理が言うべき健全なセリフの指示を日本語で記述)",

  "constraints": ["必ず健全で適切な表現を使用する", "センシティブな話題は避ける"]

}}

"""
    try:
        chat_completion = groq_client.chat.completions.create(
            messages=[{"role": "system", "content": "You must generate a response in valid JSON format."},
                      {"role": "user", "content": prompt_template}],
            model="llama3-8b-8192", temperature=0.8, response_format={"type": "json_object"},
        )
        response_content = chat_completion.choices[0].message.content
        print(f"Groqからの応答: {response_content}")  # デバッグ出力
        params = json.loads(response_content)
        
        # 安全のため、initial_dialogue_instructionを簡略化
        if "initial_dialogue_instruction" in params:
            original = params["initial_dialogue_instruction"]
            simplified = f"{scene}に来た感想を述べる"
            print(f"指示を簡略化: {original} -> {simplified}")
            params["initial_dialogue_instruction"] = simplified
            
        return params
    except Exception as e:
        print(f"指示書生成エラー(Groq): {e}")
        return None

def generate_dialogue_with_gemini(history, message, affection, stage_name, scene_params, instruction=None, use_simple_prompt=False):
    history_text = "\n".join([f"ユーザー: {u}\n麻理: {m}" for u, m in history])
    task_prompt = f"指示: {instruction}" if instruction else f"ユーザー: {message}"
    
    if use_simple_prompt:
        # シーン遷移時にも同じプロンプトを使用
        system_prompt = f"""

{SYSTEM_PROMPT_MARI}



# 現在の状況

- 現在の好感度: {affection}

- 現在の関係ステージ: {stage_name}

- 性格: {scene_params.get("personality_mod", "特になし")}

- 話し方のトーン: {scene_params.get("tone", "特になし")}



# タスク

{task_prompt}

麻理:

"""
    else:
        # 通常会話時には完全なシステムプロンプトを使用
        system_prompt = f"""

{SYSTEM_PROMPT_MARI}



# 現在の状況

- 現在の好感度: {affection}

- 現在の関係ステージ: {stage_name}

- 性格(シーン特有): {scene_params.get("personality_mod", "特になし")}

- 話し方のトーン(シーン特有): {scene_params.get("tone", "特になし")}



# 会話履歴

{history_text}

---

# タスク

{task_prompt}

麻理:

"""
    
    print(f"Geminiに応答生成をリクエストします (モード: {'シーン遷移' if instruction else '通常会話'}, 簡潔プロンプト: {use_simple_prompt})")
    print(f"プロンプト長: {len(system_prompt)}")

    try:
        generation_config = genai.types.GenerationConfig(max_output_tokens=200, temperature=0.95)
        response = gemini_model.generate_content(system_prompt, generation_config=generation_config)

        if response.candidates and response.candidates[0].finish_reason in {1, 'STOP'}:
            return response.text.strip()
        else:
            # エラー情報をログファイルに記録(デバッグ用)
            import datetime
            with open("gemini_errors.log", "a") as f:
                finish_reason = response.candidates[0].finish_reason if response.candidates else 'N/A'
                f.write(f"時刻: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
                f.write(f"応答生成が途中で終了しました。理由: {finish_reason}\n")
                f.write(f"Prompt Feedback: {response.prompt_feedback}\n")
                
                if hasattr(response, 'prompt_feedback') and response.prompt_feedback:
                    for rating in response.prompt_feedback.safety_ratings:
                        f.write(f"安全性評価: カテゴリ={rating.category}, レベル={rating.probability}\n")
                    if hasattr(response.prompt_feedback, 'blocked_reason'):
                        f.write(f"ブロック理由: {response.prompt_feedback.blocked_reason}\n")
                f.write("\n")
            
            # フォールバック:シーン名に応じた自然な応答を返す
            if instruction and "に来た感想" in instruction:
                scene = instruction.split("に来た感想")[0]
                
                # シーンに応じたフォールバック応答のバリエーション
                scene_responses = {
                    "aquarium_night": [
                        "(水槽の青い光に照らされた魚たちを見つめている)こんな時間に来ると、また違った雰囲気だな。",
                        "(暗がりの中で光る魚たちを見て)夜の水族館か…意外と悪くないかも。",
                        "(水槽に近づいて)夜になると、昼間とは違う魚が活動してるんだな。"
                    ],
                    "beach_sunset": [
                        "(夕日に照らされた海を見つめて)こんな景色、久しぶりに見たな…",
                        "(砂浜に足跡をつけながら)夕暮れの海って、なんか落ち着くな。",
                        "(波の音を聞きながら)この時間の浜辺は、人も少なくていいかも。"
                    ],
                    "festival_night": [
                        "(提灯の明かりを見上げて)意外と…悪くない雰囲気だな。",
                        "(周囲の賑わいを見回して)こういう場所は、あまり来ないんだけどな…",
                        "(屋台の匂いを感じて)なんか…懐かしい感じがするな。"
                    ],
                    "shrine_day": [
                        "(静かな境内を見回して)こういう静かな場所も、たまにはいいかも。",
                        "(鳥居を見上げて)なんか、空気が違うな、ここは。",
                        "(参道を歩きながら)静かで…落ち着くな。"
                    ],
                    "cafe_afternoon": [
                        "(窓の外を見ながら)こういう時間の過ごし方も、悪くないな。",
                        "(コーヒーの香りを感じて)ここの雰囲気、悪くないな。",
                        "(店内を見回して)意外と落ち着く場所だな、ここ。"
                    ],
                    "room_night": [
                        "(窓の外の夜景を見て)夜の景色って、なんか落ち着くな。",
                        "(部屋の明かりを見つめて)こういう静かな時間も、たまにはいいかも。",
                        "(窓際に立ち)夜の静けさって、考え事するのにちょうどいいな。"
                    ]
                }
                
                # シーン名に応じた応答を選択(なければデフォルト応答)
                import random
                for key in scene_responses:
                    if key in scene:
                        return random.choice(scene_responses[key])
                
                # デフォルト応答
                return f"({scene}の様子を静かに見回して)ここか…悪くない場所かもな。"
            else:
                return "(……何か言おうとしたけど、言葉に詰まった)"
    except Exception as e:
        print(f"応答生成エラー(Gemini): {e}")
        import traceback
        traceback.print_exc()  # スタックトレースを出力
        return "(ごめんなさい、ちょっと考えがまとまらない……)"


# --- 他の関数とUI部分は変更ありません ---
def get_relationship_stage(affection):
    if affection < 40: return "ステージ1:会話成立"
    if affection < 60: return "ステージ2:親密化"
    if affection < 80: return "ステージ3:信頼"
    return "ステージ4:最親密"

def update_affection(message, affection):
    if not sentiment_analyzer: return affection
    try:
        result = sentiment_analyzer(message)[0]
        if result['label'] == 'positive': return min(100, affection + 5)
        if result['label'] == 'negative': return max(0, affection - 5)
    except Exception:
        return affection
    return affection



def respond(message, chat_history, affection, history, scene_params):
    new_affection = update_affection(message, affection)
    stage_name = get_relationship_stage(new_affection)
    current_theme = scene_params.get("theme", "default")
    new_scene_name = detect_scene_change(history, message)
    final_scene_params = scene_params
    
    if new_scene_name and new_scene_name != current_theme:
        print(f"シーンチェンジを実行: {current_theme} -> {new_scene_name}")
        
        # シーンパラメータを更新
        new_params_base = generate_scene_instruction_with_groq(new_affection, stage_name, new_scene_name, message)
        if new_params_base:
            # Groqからの応答を精査し、問題のある可能性のあるフィールドを修正
            if isinstance(new_params_base.get("personality_mod"), dict):
                # 複雑な構造になっている場合は単純化
                new_params_base["personality_mod"] = f"{new_scene_name}での様子を観察している"
            
            if isinstance(new_params_base.get("tone"), dict):
                # 複雑な構造になっている場合は単純化
                new_params_base["tone"] = "冷静だが、少し興味を持っている様子"
            
            # 英語の指示を日本語に変換
            if "initial_dialogue_instruction" in new_params_base and new_params_base["initial_dialogue_instruction"].strip().lower().startswith("so"):
                new_params_base["initial_dialogue_instruction"] = f"{new_scene_name}の感想を述べる"
            
            final_scene_params = {**DEFAULT_SCENE_PARAMS, **new_params_base}
            # シンプルな指示を使用
            simple_instruction = f"{new_scene_name}に来た感想を述べる"
            print(f"シンプルな指示を使用: {simple_instruction}")
            
            # シーン遷移時は簡潔なプロンプトを使用してGeminiで応答を生成
            bot_message = generate_dialogue_with_gemini(history, message, new_affection, stage_name, final_scene_params, instruction=simple_instruction, use_simple_prompt=True)
        else:
            final_scene_params["theme"] = new_scene_name
            bot_message = generate_dialogue_with_gemini(history, message, new_affection, stage_name, final_scene_params)
    else:
        # 通常会話はGeminiを使用
        bot_message = generate_dialogue_with_gemini(history, message, new_affection, stage_name, final_scene_params)
    
    new_history = history + [(message, bot_message)]
    chat_history.append((message, bot_message))
    theme_name = final_scene_params.get("theme", "default")
    
    # より強力な背景更新用のHTMLを生成
    background_html = f'''

    <div class="background-container">

        <div class="chat-background {theme_name}"></div>

    </div>

    <style>

    .chat-background {{

        background-image: url({THEME_URLS.get(theme_name, THEME_URLS["default"])}) !important;

    }}

    </style>

    '''
    
    return "", chat_history, new_affection, stage_name, new_affection, new_history, final_scene_params, background_html

# カスタムCSSを読み込む
with open("style.css", "r") as f:
    custom_css = f.read()

# Gradio 5.x用のシンプルなテーマ設定
custom_theme = gr.themes.Soft(
    primary_hue="rose",
    secondary_hue="pink",
)

# Gradio 5.xでのテーマカスタマイズ(最小限の設定のみ)
try:
    # 透明度を持つ背景色を設定(Gradio 5.0で確実に動作するプロパティのみ)
    custom_theme = gr.themes.Soft(
        primary_hue="rose",
        secondary_hue="pink",
        neutral_hue="slate",
        spacing_size="sm",
        radius_size="lg",
    )
except Exception as e:
    print(f"テーマカスタマイズエラー: {e}")
    # エラーが発生した場合はデフォルトのテーマを使用

with gr.Blocks(css=custom_css, theme=custom_theme) as demo:
    scene_state = gr.State(DEFAULT_SCENE_PARAMS)
    affection_state = gr.State(30)
    history_state = gr.State([])
    
    # 背景コンテナを先に配置(固定位置で全画面に)
    background_display = gr.HTML(f'''

    <div class="background-container">

        <div class="chat-background {DEFAULT_SCENE_PARAMS["theme"]}"></div>

    </div>

    ''', elem_id="background_container")
    
    # ヘッダー部分(背景と分離)
    with gr.Group(elem_classes="header-box"):
        gr.Markdown("# 麻理チャット")
    
    with gr.Row():
        with gr.Column(scale=2):
            # チャットコンテナ(背景と分離)
            with gr.Group(elem_id="chat_container", elem_classes="chat-box"):
                # Gradio 5.x用のChatbot設定
                chatbot = gr.Chatbot(
                    label="麻理との会話", 
                    elem_id="chat_area", 
                    show_label=False,
                    height=400,
                    type="messages",  # 警告を解消するために追加
                    avatar_images=[
                        "https://cdn.pixabay.com/photo/2016/04/01/10/04/amusing-1299756_1280.png", 
                        "https://cdn.pixabay.com/photo/2016/03/31/21/40/bot-1296595_1280.png"
                    ]
                )
            # 入力欄(背景と分離)
            with gr.Group(elem_classes="input-box"):
                msg_input = gr.Textbox(label="あなたのメッセージ", placeholder="「水族館はどう?」と聞いた後、「いいね、行こう!」のように返してみてください", show_label=False)
        
        # ステータス部分(右側、背景と分離)
        with gr.Column(scale=1):
            with gr.Group(elem_classes="status-box"):
                stage_display = gr.Textbox(label="現在の関係ステージ", interactive=False, value=get_relationship_stage(30))
                affection_gauge = gr.Slider(minimum=0, maximum=100, label="麻理の好感度", value=30, interactive=False)
    
    # フッター部分(背景と分離)
    with gr.Group(elem_classes="footer-box"):
        gr.Markdown("""

        <div style="font-size: 0.8em; text-align: center; opacity: 0.7;">

        背景画像: <a href="https://pixabay.com" target="_blank">Pixabay</a> | 

        アイコン: <a href="https://pixabay.com" target="_blank">Pixabay</a>

        </div>

        """)
    msg_input.submit(
        respond,
        [msg_input, chatbot, affection_state, history_state, scene_state],
        [msg_input, chatbot, affection_gauge, stage_display, affection_state, history_state, scene_state, background_display]
    )
    demo.load(lambda affection: get_relationship_stage(affection), affection_state, stage_display)

if __name__ == "__main__":
    demo.launch()