Merge pull request #2826 from xinnan-tech/test_page_fix

update:增加live2d模型,修改测试页面样式
This commit is contained in:
hrz
2026-01-12 17:44:30 +08:00
committed by GitHub
37 changed files with 59976 additions and 1282 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
============================================================
示例模型
桃濑日和 - PRO
============================================================
该示例时基于Cubism3.0制作的标准模型素材。
可用于学习变形器的构造以及参数的使用方法。
模型的肩部应用了新功能【胶水】。
------------------------------
素材使用许可
------------------------------
 普通用户以及小规模企业在同意授权协议的情况下可用于商业用途。
 中/大规模的企业只能用于非公开的内部试用。
 在使用该素材时,请确认以下的【无偿提供素材使用授权协议】中的“授权类型”、“Live2D原创角色”等的相关内容,
 并必须接受【Live2D Cubism 示例模型的使用授权要求】中的利用条件。
 有关许可证的更多信息,请参阅以下页面。
 https://www.live2d.com/zh-CHS/download/sample-data/
------------------------------
创作者
------------------------------
插画:Kani Biimu
模型:Live2D
------------------------------
素材内容
------------------------------
模型文件(cmo3) ※包含物理模拟的设定
动画文件(can3)
嵌入文件列表(runtime文件夹)
・模型数据(moc3)
・动作数据(motion3.json)
・模型设定文件(model3.json)
・物理模拟设定文件(physics3.json)
・姿势设定文件(pose3.json)
・辅助显示的文件(cdi3.json)
------------------------------
更新记录
------------------------------
【cmo3】
 hiyori_pro_t11
 2023年03月08日 修改了部分模型关键点
hiyori_pro_t10
2021年06月10日 公开
【can3】
 hiyori_pro_t04
 2023年03月08日 修改了部分动画关键帧
hiyori_pro_t03
2021年06月10日 公开
 
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

@@ -0,0 +1,533 @@
{
"Version": 3,
"Parameters": [
{
"Id": "ParamAngleX",
"GroupId": "ParamGroupFace",
"Name": "角度 X"
},
{
"Id": "ParamAngleY",
"GroupId": "ParamGroupFace",
"Name": "角度 Y"
},
{
"Id": "ParamAngleZ",
"GroupId": "ParamGroupFace",
"Name": "角度 Z"
},
{
"Id": "ParamCheek",
"GroupId": "ParamGroupFace",
"Name": "脸颊泛红"
},
{
"Id": "ParamEyeLOpen",
"GroupId": "ParamGroupEyes",
"Name": "左眼 开闭"
},
{
"Id": "ParamEyeLSmile",
"GroupId": "ParamGroupEyes",
"Name": "左眼 微笑"
},
{
"Id": "ParamEyeROpen",
"GroupId": "ParamGroupEyes",
"Name": "右眼 开闭"
},
{
"Id": "ParamEyeRSmile",
"GroupId": "ParamGroupEyes",
"Name": "右眼 微笑"
},
{
"Id": "ParamEyeBallX",
"GroupId": "ParamGroupEyeballs",
"Name": "眼珠 X"
},
{
"Id": "ParamEyeBallY",
"GroupId": "ParamGroupEyeballs",
"Name": "眼珠 Y"
},
{
"Id": "ParamBrowLY",
"GroupId": "ParamGroupBrows",
"Name": "左眉 上下"
},
{
"Id": "ParamBrowRY",
"GroupId": "ParamGroupBrows",
"Name": "右眉 上下"
},
{
"Id": "ParamBrowLX",
"GroupId": "ParamGroupBrows",
"Name": "左眉 左右"
},
{
"Id": "ParamBrowRX",
"GroupId": "ParamGroupBrows",
"Name": "右眉 左右"
},
{
"Id": "ParamBrowLAngle",
"GroupId": "ParamGroupBrows",
"Name": "左眉 角度"
},
{
"Id": "ParamBrowRAngle",
"GroupId": "ParamGroupBrows",
"Name": "右眉 角度"
},
{
"Id": "ParamBrowLForm",
"GroupId": "ParamGroupBrows",
"Name": "左眉 变形"
},
{
"Id": "ParamBrowRForm",
"GroupId": "ParamGroupBrows",
"Name": "右眉 变形"
},
{
"Id": "ParamMouthForm",
"GroupId": "ParamGroupMouth",
"Name": "嘴 变形"
},
{
"Id": "ParamMouthOpenY",
"GroupId": "ParamGroupMouth",
"Name": "嘴 开闭"
},
{
"Id": "ParamBodyAngleX",
"GroupId": "ParamGroupBody",
"Name": "身体的旋转 X"
},
{
"Id": "ParamBodyAngleY",
"GroupId": "ParamGroupBody",
"Name": "身体的旋转 Y"
},
{
"Id": "ParamBodyAngleZ",
"GroupId": "ParamGroupBody",
"Name": "身体的旋转 Z"
},
{
"Id": "ParamBreath",
"GroupId": "ParamGroupBody",
"Name": "呼吸"
},
{
"Id": "ParamShoulder",
"GroupId": "ParamGroupBody",
"Name": "肩"
},
{
"Id": "ParamLeg",
"GroupId": "ParamGroupBody",
"Name": "腿"
},
{
"Id": "ParamArmLA",
"GroupId": "ParamGroupArms",
"Name": "左臂 A"
},
{
"Id": "ParamArmRA",
"GroupId": "ParamGroupArms",
"Name": "右臂 A"
},
{
"Id": "ParamArmLB",
"GroupId": "ParamGroupArms",
"Name": "左臂 B"
},
{
"Id": "ParamArmRB",
"GroupId": "ParamGroupArms",
"Name": "右臂 B"
},
{
"Id": "ParamHandLB",
"GroupId": "ParamGroupArms",
"Name": "左手B 旋转"
},
{
"Id": "ParamHandRB",
"GroupId": "ParamGroupArms",
"Name": "右手B 旋转"
},
{
"Id": "ParamHandL",
"GroupId": "ParamGroupArms",
"Name": "左手"
},
{
"Id": "ParamHandR",
"GroupId": "ParamGroupArms",
"Name": "右手"
},
{
"Id": "ParamBustY",
"GroupId": "ParamGroupSway",
"Name": "胸部 摇动"
},
{
"Id": "ParamHairAhoge",
"GroupId": "ParamGroupSway",
"Name": "头发摇动 呆毛"
},
{
"Id": "ParamHairFront",
"GroupId": "ParamGroupSway",
"Name": "头发摇动 前"
},
{
"Id": "ParamHairBack",
"GroupId": "ParamGroupSway",
"Name": "头发摇动 后"
},
{
"Id": "ParamSideupRibbon",
"GroupId": "ParamGroupSway",
"Name": "发饰的摇动"
},
{
"Id": "ParamRibbon",
"GroupId": "ParamGroupSway",
"Name": "蝴蝶结的摇动"
},
{
"Id": "ParamSkirt",
"GroupId": "ParamGroupSway",
"Name": "短裙的摇动"
},
{
"Id": "ParamSkirt2",
"GroupId": "ParamGroupSway",
"Name": "短裙的上卷"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[0]辫子左"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[1]辫子左"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[2]辫子左"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[3]辫子左"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[4]辫子左"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[5]辫子左"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh62",
"GroupId": "ParamGroup2",
"Name": "[6]辫子左"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[0]辫子右"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[1]辫子右"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[2]辫子右"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[3]辫子右"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[4]辫子右"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[5]辫子右"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh61",
"GroupId": "ParamGroup",
"Name": "[6]辫子右"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[0]侧发左"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[1]侧发左"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[2]侧发左"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[3]侧发左"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[4]侧发左"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[5]侧发左"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh55",
"GroupId": "ParamGroup4",
"Name": "[6]侧发左"
},
{
"Id": "Param_Angle_Rotation_1_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[0]侧发右"
},
{
"Id": "Param_Angle_Rotation_2_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[1]侧发右"
},
{
"Id": "Param_Angle_Rotation_3_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[2]侧发右"
},
{
"Id": "Param_Angle_Rotation_4_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[3]侧发右"
},
{
"Id": "Param_Angle_Rotation_5_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[4]侧发右"
},
{
"Id": "Param_Angle_Rotation_6_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[5]侧发右"
},
{
"Id": "Param_Angle_Rotation_7_ArtMesh54",
"GroupId": "ParamGroup3",
"Name": "[6]侧发右"
}
],
"ParameterGroups": [
{
"Id": "ParamGroupFace",
"GroupId": "",
"Name": "脸"
},
{
"Id": "ParamGroupEyes",
"GroupId": "",
"Name": "眼睛"
},
{
"Id": "ParamGroupEyeballs",
"GroupId": "",
"Name": "眼珠"
},
{
"Id": "ParamGroupBrows",
"GroupId": "",
"Name": "眉毛"
},
{
"Id": "ParamGroupMouth",
"GroupId": "",
"Name": "嘴"
},
{
"Id": "ParamGroupBody",
"GroupId": "",
"Name": "身体"
},
{
"Id": "ParamGroupArms",
"GroupId": "",
"Name": "手臂"
},
{
"Id": "ParamGroupSway",
"GroupId": "",
"Name": "摇动"
},
{
"Id": "ParamGroup2",
"GroupId": "",
"Name": "摇动 辫子左"
},
{
"Id": "ParamGroup",
"GroupId": "",
"Name": "摇动 辫子右"
},
{
"Id": "ParamGroup4",
"GroupId": "",
"Name": "摇动 侧发左"
},
{
"Id": "ParamGroup3",
"GroupId": "",
"Name": "摇动 侧发右"
}
],
"Parts": [
{
"Id": "PartCore",
"Name": "Core"
},
{
"Id": "PartCheek",
"Name": "脸颊"
},
{
"Id": "PartBrow",
"Name": "眉毛"
},
{
"Id": "PartEye",
"Name": "眼睛"
},
{
"Id": "PartNose",
"Name": "鼻子"
},
{
"Id": "PartMouth",
"Name": "嘴"
},
{
"Id": "PartFace",
"Name": "脸"
},
{
"Id": "PartEar",
"Name": "耳朵"
},
{
"Id": "PartHairSide",
"Name": "侧发"
},
{
"Id": "PartHairFront",
"Name": "前发"
},
{
"Id": "PartHairBack",
"Name": "后发"
},
{
"Id": "PartNeck",
"Name": "脖子"
},
{
"Id": "PartArmA",
"Name": "手臂A"
},
{
"Id": "PartArmB",
"Name": "手臂B"
},
{
"Id": "PartBody",
"Name": "身体"
},
{
"Id": "PartBackground",
"Name": "背景"
},
{
"Id": "PartSketch",
"Name": "[参考图]"
},
{
"Id": "PartEyeBall",
"Name": "眼珠"
},
{
"Id": "ArtMesh55_Skinning",
"Name": "侧发右"
},
{
"Id": "Part4",
"Name": "侧发左(旋转)"
},
{
"Id": "ArtMesh54_Skinning",
"Name": "侧发右(蒙皮)"
},
{
"Id": "Part3",
"Name": "侧发右(旋转)"
},
{
"Id": "ArtMesh61_Skinning",
"Name": "辫子右(蒙皮)"
},
{
"Id": "Part",
"Name": "辫子右(旋转)"
},
{
"Id": "ArtMesh62_Skinning",
"Name": "辫子左(蒙皮)"
},
{
"Id": "Part2",
"Name": "辫子左(旋转)"
}
],
"CombinedParameters": [
[
"ParamAngleX",
"ParamAngleY"
],
[
"ParamMouthForm",
"ParamMouthOpenY"
]
]
}
@@ -0,0 +1,82 @@
{
"Version": 3,
"FileReferences": {
"Moc": "hiyori_pro_t11.moc3",
"Textures": [
"hiyori_pro_t11.2048/texture_00.png",
"hiyori_pro_t11.2048/texture_01.png"
],
"Physics": "hiyori_pro_t11.physics3.json",
"Pose": "hiyori_pro_t11.pose3.json",
"DisplayInfo": "hiyori_pro_t11.cdi3.json",
"Motions": {
"Idle": [
{
"File": "motion/hiyori_m01.motion3.json"
},
{
"File": "motion/hiyori_m02.motion3.json"
},
{
"File": "motion/hiyori_m05.motion3.json"
}
],
"Flick": [
{
"File": "motion/hiyori_m03.motion3.json"
}
],
"FlickDown": [
{
"File": "motion/hiyori_m04.motion3.json"
}
],
"FlickUp": [
{
"File": "motion/hiyori_m06.motion3.json"
}
],
"Tap": [
{
"File": "motion/hiyori_m07.motion3.json"
},
{
"File": "motion/hiyori_m08.motion3.json"
}
],
"Tap@Body": [
{
"File": "motion/hiyori_m09.motion3.json"
}
],
"Flick@Body": [
{
"File": "motion/hiyori_m10.motion3.json"
}
]
}
},
"Groups": [
{
"Target": "Parameter",
"Name": "LipSync",
"Ids": [
"ParamMouthOpenY"
]
},
{
"Target": "Parameter",
"Name": "EyeBlink",
"Ids": [
"ParamEyeLOpen",
"ParamEyeROpen"
]
}
],
"HitAreas": [
{
"Id": "HitArea",
"Name": "Body"
}
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
{
"Type": "Live2D Pose",
"Groups": [
[
{
"Id": "PartArmA",
"Link": []
},
{
"Id": "PartArmB",
"Link": []
}
]
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,860 @@
{
"Version": 3,
"Meta": {
"Duration": 4.43,
"Fps": 30.0,
"Loop": true,
"AreBeziersRestricted": false,
"CurveCount": 29,
"TotalSegmentCount": 106,
"TotalPointCount": 287,
"UserDataCount": 0,
"TotalUserDataSize": 0
},
"Curves": [
{
"Target": "Parameter",
"Id": "ParamAngleX",
"Segments": [
0,
1,
1,
0.211,
1,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
5,
1.467,
5,
1,
1.689,
5,
1.911,
-16,
2.133,
-16,
1,
2.356,
-16,
2.578,
13.871,
2.8,
13.871,
1,
2.956,
13.871,
3.111,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleY",
"Segments": [
0,
0,
1,
0.211,
0,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
-25,
1.467,
-25,
1,
1.689,
-25,
1.911,
-15.225,
2.133,
-11,
1,
2.356,
-6.775,
2.578,
-5.127,
2.8,
-2.5,
1,
2.956,
-0.661,
3.111,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleZ",
"Segments": [
0,
0,
1,
0.222,
0,
0.444,
0,
0.667,
0,
1,
0.756,
0,
0.844,
-4,
0.933,
-4,
1,
1.122,
-4,
1.311,
18,
1.5,
18,
1,
1.722,
18,
1.944,
-14,
2.167,
-14,
1,
2.567,
-14,
2.967,
-14,
3.367,
-14,
1,
3.511,
-14,
3.656,
-12,
3.8,
-12,
0,
4.433,
-12
]
},
{
"Target": "Parameter",
"Id": "ParamCheek",
"Segments": [
0,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLOpen",
"Segments": [
0,
1.2,
1,
0.389,
1.2,
0.778,
1.148,
1.167,
1,
1,
1.211,
0.983,
1.256,
0,
1.3,
0,
1,
1.322,
0,
1.344,
0,
1.367,
0,
1,
1.422,
0,
1.478,
1,
1.533,
1,
1,
1.944,
1,
2.356,
1,
2.767,
1,
1,
2.811,
1,
2.856,
0,
2.9,
0,
1,
2.922,
0,
2.944,
0,
2.967,
0,
1,
3.022,
0,
3.078,
1,
3.133,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLSmile",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeROpen",
"Segments": [
0,
1.2,
1,
0.389,
1.2,
0.778,
1.148,
1.167,
1,
1,
1.211,
0.983,
1.256,
0,
1.3,
0,
1,
1.322,
0,
1.344,
0,
1.367,
0,
1,
1.422,
0,
1.478,
1,
1.533,
1,
1,
1.944,
1,
2.356,
1,
2.767,
1,
1,
2.811,
1,
2.856,
0,
2.9,
0,
1,
2.922,
0,
2.944,
0,
2.967,
0,
1,
3.022,
0,
3.078,
1,
3.133,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamEyeRSmile",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallX",
"Segments": [
0,
0,
1,
0.211,
0,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
-0.44,
1.467,
-0.44,
1,
1.689,
-0.44,
1.911,
0.79,
2.133,
0.79,
1,
2.511,
0.79,
2.889,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallY",
"Segments": [
0,
0,
1,
0.211,
0,
0.422,
0,
0.633,
0,
1,
0.911,
0,
1.189,
-1,
1.467,
-1,
1,
1.689,
-1,
1.911,
-1,
2.133,
-1,
1,
2.511,
-1,
2.889,
0,
3.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLY",
"Segments": [
0,
1,
1,
0.544,
1,
1.089,
1,
1.633,
1,
1,
1.856,
1,
2.078,
0,
2.3,
0,
1,
2.5,
0,
2.7,
1,
2.9,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRY",
"Segments": [
0,
1,
1,
0.544,
1,
1.089,
1,
1.633,
1,
1,
1.856,
1,
2.078,
0,
2.3,
0,
1,
2.5,
0,
2.7,
1,
2.9,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLX",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRX",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLAngle",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRAngle",
"Segments": [
0,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLForm",
"Segments": [
0,
-1,
0,
4.433,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRForm",
"Segments": [
0,
-1,
0,
4.433,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleX",
"Segments": [
0,
0,
1,
0.244,
0,
0.489,
0,
0.733,
0,
1,
0.933,
0,
1.133,
-7,
1.333,
-7,
1,
1.644,
-7,
1.956,
0,
2.267,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleY",
"Segments": [
0,
0,
1,
0.244,
0,
0.489,
0,
0.733,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleZ",
"Segments": [
0,
2,
1,
0.233,
2,
0.467,
0,
0.7,
0,
1,
0.733,
0,
0.767,
0,
0.8,
0,
1,
1,
0,
1.2,
-4,
1.4,
-4,
1,
1.711,
-4,
2.022,
5,
2.333,
5,
1,
2.567,
5,
2.8,
3.64,
3.033,
0,
1,
3.133,
-1.56,
3.233,
-3,
3.333,
-3,
1,
3.467,
-3,
3.6,
-2,
3.733,
-2,
0,
4.433,
-2
]
},
{
"Target": "Parameter",
"Id": "ParamBreath",
"Segments": [
0,
0,
1,
0.189,
0,
0.378,
1,
0.567,
1,
1,
0.711,
1,
0.856,
0,
1,
0,
1,
1.222,
0,
1.444,
1,
1.667,
1,
1,
1.889,
1,
2.111,
0,
2.333,
0,
1,
2.544,
0,
2.756,
1,
2.967,
1,
1,
3.167,
1,
3.367,
0,
3.567,
0,
0,
4.433,
0
]
},
{
"Target": "Parameter",
"Id": "ParamShoulder",
"Segments": [
0,
0.1,
1,
0.467,
0.1,
0.933,
1,
1.4,
1,
1,
1.844,
1,
2.289,
1,
2.733,
1,
1,
2.967,
1,
3.2,
-1,
3.433,
-1,
0,
4.433,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamLeg",
"Segments": [
0,
1,
0,
4.433,
1
]
},
{
"Target": "Parameter",
"Id": "ParamArmLA",
"Segments": [
0,
-10,
0,
4.433,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamArmRA",
"Segments": [
0,
-10,
0,
4.433,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamHairAhoge",
"Segments": [
0,
0,
1,
0.3,
0,
0.6,
0,
0.9,
-0.012,
1,
1.067,
-0.019,
1.233,
-6.827,
1.4,
-6.827,
1,
1.511,
-6.827,
1.622,
7.958,
1.733,
7.958,
1,
1.944,
7.958,
2.156,
-7.565,
2.367,
-7.565,
1,
2.5,
-7.565,
2.633,
9.434,
2.767,
9.434,
1,
2.978,
9.434,
3.189,
-8.871,
3.4,
-8.871,
1,
3.5,
-8.871,
3.6,
7.588,
3.7,
7.588,
1,
3.789,
7.588,
3.878,
-3.904,
3.967,
-3.904,
1,
4.011,
-3.904,
4.056,
-0.032,
4.1,
-0.032,
0,
4.433,
-0.032
]
},
{
"Target": "PartOpacity",
"Id": "PartArmA",
"Segments": [
0,
1,
0,
4.43,
1
]
},
{
"Target": "PartOpacity",
"Id": "PartArmB",
"Segments": [
0,
0,
0,
4.43,
0
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,927 @@
{
"Version": 3,
"Meta": {
"Duration": 1.9,
"Fps": 30.0,
"Loop": true,
"AreBeziersRestricted": false,
"CurveCount": 30,
"TotalSegmentCount": 121,
"TotalPointCount": 331,
"UserDataCount": 0,
"TotalUserDataSize": 0
},
"Curves": [
{
"Target": "Parameter",
"Id": "ParamAngleX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamAngleZ",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.444,
0,
0.556,
8,
0.667,
8,
0,
1.9,
8
]
},
{
"Target": "Parameter",
"Id": "ParamCheek",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLOpen",
"Segments": [
0,
1,
1,
0.111,
1,
0.222,
1,
0.333,
1,
1,
0.378,
1,
0.422,
0,
0.467,
0,
1,
0.522,
0,
0.578,
1.2,
0.633,
1.2,
1,
0.744,
1.2,
0.856,
1.2,
0.967,
1.2,
1,
0.989,
1.2,
1.011,
0,
1.033,
0,
1,
1.067,
0,
1.1,
1.2,
1.133,
1.2,
1,
1.167,
1.2,
1.2,
1.2,
1.233,
1.2,
1,
1.267,
1.2,
1.3,
0,
1.333,
0,
1,
1.356,
0,
1.378,
1.2,
1.4,
1.2,
0,
1.9,
1.2
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLSmile",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeROpen",
"Segments": [
0,
1,
1,
0.111,
1,
0.222,
1,
0.333,
1,
1,
0.378,
1,
0.422,
0,
0.467,
0,
1,
0.522,
0,
0.578,
1.2,
0.633,
1.2,
1,
0.744,
1.2,
0.856,
1.2,
0.967,
1.2,
1,
0.989,
1.2,
1.011,
0,
1.033,
0,
1,
1.067,
0,
1.1,
1.2,
1.133,
1.2,
1,
1.167,
1.2,
1.2,
1.2,
1.233,
1.2,
1,
1.267,
1.2,
1.3,
0,
1.333,
0,
1,
1.356,
0,
1.378,
1.2,
1.4,
1.2,
0,
1.9,
1.2
]
},
{
"Target": "Parameter",
"Id": "ParamEyeRSmile",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.26,
0.667,
0.26,
0,
1.9,
0.26
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.36,
0.667,
0.36,
0,
1.9,
0.36
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0,
0.667,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
-0.27,
0.667,
-0.27,
0,
1.9,
-0.27
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLAngle",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.26,
0.667,
0.26,
0,
1.9,
0.26
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRAngle",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
-0.03,
0.667,
-0.03,
0,
1.9,
-0.03
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLForm",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.33,
0.667,
0.33,
0,
1.9,
0.33
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRForm",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.356,
0,
0.378,
0,
0.4,
0,
1,
0.489,
0,
0.578,
0.21,
0.667,
0.21,
0,
1.9,
0.21
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleX",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.444,
0,
0.556,
-6,
0.667,
-6,
0,
1.9,
-6
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleY",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.422,
0,
0.511,
10,
0.6,
10,
1,
0.667,
10,
0.733,
-6,
0.8,
-6,
1,
0.833,
-6,
0.867,
5,
0.9,
5,
1,
1.011,
5,
1.122,
0,
1.233,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleZ",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.444,
0,
0.556,
-3,
0.667,
-3,
0,
1.9,
-3
]
},
{
"Target": "Parameter",
"Id": "ParamBreath",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamShoulder",
"Segments": [
0,
-0.062,
1,
0.111,
-0.062,
0.222,
-0.103,
0.333,
0,
1,
0.589,
0.238,
0.844,
1,
1.1,
1,
0,
1.9,
1
]
},
{
"Target": "Parameter",
"Id": "ParamArmLA",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.478,
0,
0.622,
-10,
0.767,
-10,
1,
0.811,
-10,
0.856,
-8.2,
0.9,
-8.2,
0,
1.9,
-8.2
]
},
{
"Target": "Parameter",
"Id": "ParamArmRA",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
1,
0.478,
0,
0.622,
-10,
0.767,
-10,
1,
0.811,
-10,
0.856,
-7.2,
0.9,
-7.2,
0,
1.9,
-7.2
]
},
{
"Target": "Parameter",
"Id": "ParamArmLB",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamArmRB",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
0,
0.333,
0,
0,
1.9,
0
]
},
{
"Target": "Parameter",
"Id": "ParamHairAhoge",
"Segments": [
0,
0,
1,
0.111,
0,
0.222,
1.9,
0.333,
5.2,
1,
0.444,
8.5,
0.556,
9.926,
0.667,
9.926,
1,
0.744,
9.926,
0.822,
-10,
0.9,
-10,
1,
0.956,
-10,
1.011,
6,
1.067,
6,
1,
1.144,
6,
1.222,
-4,
1.3,
-4,
1,
1.367,
-4,
1.433,
0,
1.5,
0,
0,
1.9,
0
]
},
{
"Target": "PartOpacity",
"Id": "PartArmA",
"Segments": [
0,
1,
0,
1.9,
1
]
},
{
"Target": "PartOpacity",
"Id": "PartArmB",
"Segments": [
0,
0,
0,
1.9,
0
]
}
]
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,924 @@
{
"Version": 3,
"Meta": {
"Duration": 4.17,
"Fps": 30.0,
"Loop": true,
"AreBeziersRestricted": false,
"CurveCount": 31,
"TotalSegmentCount": 118,
"TotalPointCount": 321,
"UserDataCount": 0,
"TotalUserDataSize": 0
},
"Curves": [
{
"Target": "Parameter",
"Id": "ParamAngleX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.4,
0,
0.6,
0,
0.8,
0,
1,
1.067,
0,
1.333,
1.041,
1.6,
1.041,
1,
1.844,
1.041,
2.089,
-8,
2.333,
-8,
1,
2.656,
-8,
2.978,
6,
3.3,
6,
0,
4.167,
6
]
},
{
"Target": "Parameter",
"Id": "ParamAngleY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.344,
0,
0.489,
-30,
0.633,
-30,
0,
4.167,
-30
]
},
{
"Target": "Parameter",
"Id": "ParamAngleZ",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamCheek",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLOpen",
"Segments": [
0,
1,
1,
0.067,
1,
0.133,
1,
0.2,
1,
1,
0.311,
1,
0.422,
0.988,
0.533,
0.8,
1,
0.589,
0.706,
0.644,
0,
0.7,
0,
1,
0.722,
0,
0.744,
0,
0.767,
0,
1,
0.822,
0,
0.878,
0.8,
0.933,
0.8,
1,
1.422,
0.8,
1.911,
0.8,
2.4,
0.8,
1,
2.456,
0.8,
2.511,
0,
2.567,
0,
1,
2.589,
0,
2.611,
0,
2.633,
0,
1,
2.689,
0,
2.744,
0.8,
2.8,
0.8,
0,
4.167,
0.8
]
},
{
"Target": "Parameter",
"Id": "ParamEyeLSmile",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeROpen",
"Segments": [
0,
1,
1,
0.067,
1,
0.133,
1,
0.2,
1,
1,
0.311,
1,
0.422,
0.988,
0.533,
0.8,
1,
0.589,
0.706,
0.644,
0,
0.7,
0,
1,
0.722,
0,
0.744,
0,
0.767,
0,
1,
0.822,
0,
0.878,
0.8,
0.933,
0.8,
1,
1.422,
0.8,
1.911,
0.8,
2.4,
0.8,
1,
2.456,
0.8,
2.511,
0,
2.567,
0,
1,
2.589,
0,
2.611,
0,
2.633,
0,
1,
2.689,
0,
2.744,
0.8,
2.8,
0.8,
0,
4.167,
0.8
]
},
{
"Target": "Parameter",
"Id": "ParamEyeRSmile",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0,
0.433,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0,
0.433,
0,
1,
0.667,
0,
0.9,
0.004,
1.133,
-0.01,
1,
1.4,
-0.025,
1.667,
-0.43,
1.933,
-0.43,
1,
2.211,
-0.43,
2.489,
0.283,
2.767,
0.283,
0,
4.167,
0.283
]
},
{
"Target": "Parameter",
"Id": "ParamEyeBallY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-1,
0.433,
-1,
0,
4.167,
-1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0.19,
0.433,
0.19,
0,
4.167,
0.19
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0.11,
0.433,
0.11,
0,
4.167,
0.11
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.48,
0.433,
-0.48,
0,
4.167,
-0.48
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.29,
0.433,
-0.29,
0,
4.167,
-0.29
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLAngle",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
1,
0.433,
1,
0,
4.167,
1
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRAngle",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
0.85,
0.433,
0.85,
0,
4.167,
0.85
]
},
{
"Target": "Parameter",
"Id": "ParamBrowLForm",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.75,
0.433,
-0.75,
0,
4.167,
-0.75
]
},
{
"Target": "Parameter",
"Id": "ParamBrowRForm",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.278,
0,
0.356,
-0.87,
0.433,
-0.87,
0,
4.167,
-0.87
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleX",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.444,
0,
0.689,
0,
0.933,
0,
1,
1.211,
0,
1.489,
0,
1.767,
0,
1,
2.056,
0,
2.344,
-6,
2.633,
-6,
1,
3.033,
-6,
3.433,
10,
3.833,
10,
0,
4.167,
10
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleY",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamBodyAngleZ",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.8,
0,
1.4,
-2,
2,
-2,
1,
2.456,
-2,
2.911,
8.125,
3.367,
8.125,
0,
4.167,
8.125
]
},
{
"Target": "Parameter",
"Id": "ParamBreath",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamShoulder",
"Segments": [
0,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamLeg",
"Segments": [
0,
1,
1,
0.667,
1,
1.333,
1,
2,
1,
1,
2.267,
1,
2.533,
0.948,
2.8,
0.948,
0,
4.167,
0.948
]
},
{
"Target": "Parameter",
"Id": "ParamArmLA",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.233,
0,
0.267,
0,
0.3,
0,
1,
0.478,
0,
0.656,
-10,
0.833,
-10,
1,
0.922,
-10,
1.011,
-8.846,
1.1,
-8.846,
1,
1.467,
-8.846,
1.833,
-8.835,
2.2,
-9.1,
1,
2.622,
-9.405,
3.044,
-10,
3.467,
-10,
0,
4.167,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamArmRA",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.233,
0,
0.267,
0,
0.3,
0,
1,
0.478,
0,
0.656,
-10,
0.833,
-10,
1,
0.922,
-10,
1.011,
-8.972,
1.1,
-8.846,
1,
1.467,
-8.328,
1.833,
-8.2,
2.2,
-8.2,
1,
2.622,
-8.2,
3.044,
-10,
3.467,
-10,
0,
4.167,
-10
]
},
{
"Target": "Parameter",
"Id": "ParamArmLB",
"Segments": [
0,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamArmRB",
"Segments": [
0,
0,
0,
4.167,
0
]
},
{
"Target": "Parameter",
"Id": "ParamHairAhoge",
"Segments": [
0,
0,
1,
0.067,
0,
0.133,
0,
0.2,
0,
1,
0.233,
0,
0.267,
-5,
0.3,
-5,
1,
0.378,
-5,
0.456,
10,
0.533,
10,
1,
0.633,
10,
0.733,
4,
0.833,
4,
0,
4.167,
4
]
},
{
"Target": "PartOpacity",
"Id": "PartArmA",
"Segments": [
0,
1,
0,
4.17,
1
]
},
{
"Target": "PartOpacity",
"Id": "PartArmB",
"Segments": [
0,
0,
0,
4.17,
0
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

+40 -2
View File
@@ -1,7 +1,7 @@
// 主应用入口
import { log } from './utils/logger.js';
import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js';
import { getUIController } from './ui/controller.js';
import { uiController } from './ui/controller.js';
import { getAudioPlayer } from './core/audio/player.js';
import { initMcpTools } from './core/mcp/tools.js';
@@ -10,6 +10,7 @@ class App {
constructor() {
this.uiController = null;
this.audioPlayer = null;
this.live2dManager = null;
}
// 初始化应用
@@ -17,7 +18,7 @@ class App {
log('正在初始化应用...', 'info');
// 初始化UI控制器
this.uiController = getUIController();
this.uiController = uiController;
this.uiController.init();
// 检查Opus库
@@ -33,13 +34,50 @@ class App {
// 初始化MCP工具
initMcpTools();
// 初始化Live2D
await this.initLive2D();
log('应用初始化完成', 'success');
}
// 初始化Live2D
async initLive2D() {
try {
// 检查Live2DManager是否已加载
if (typeof window.Live2DManager === 'undefined') {
throw new Error('Live2DManager未加载,请检查脚本引入顺序');
}
this.live2dManager = new window.Live2DManager();
await this.live2dManager.initializeLive2D();
// 更新UI状态
const live2dStatus = document.getElementById('live2dStatus');
if (live2dStatus) {
live2dStatus.textContent = '● 已加载';
live2dStatus.className = 'status loaded';
}
log('Live2D初始化完成', 'success');
} catch (error) {
log(`Live2D初始化失败: ${error.message}`, 'error');
// 更新UI状态
const live2dStatus = document.getElementById('live2dStatus');
if (live2dStatus) {
live2dStatus.textContent = '● 加载失败';
live2dStatus.className = 'status error';
}
}
}
}
// 创建并启动应用
const app = new App();
// 将应用实例暴露到全局,供其他模块访问
window.chatApp = app;
// DOM加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => app.init());
@@ -326,6 +326,11 @@ export class AudioRecorder {
this.startVisualization(dataArray);
}
// 立即通知录音开始,更新按钮状态
if (this.onRecordingStart) {
this.onRecordingStart(0);
}
// 启动录音计时器
let recordingSeconds = 0;
this.recordingTimer = setInterval(() => {
@@ -23,6 +23,10 @@ export class StreamingContext {
this.totalSamples = 0; // 累积的总样本数
this.lastPlayTime = 0; // 上次播放的时间戳
this.scheduledEndTime = 0; // 已调度音频的结束时间
// 初始化分析器节点(供Live2D使用)
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
}
// 缓存音频数组
@@ -108,6 +112,11 @@ export class StreamingContext {
log('音频缓冲已清空', 'success');
}
// 获取分析器节点(供Live2D使用)
getAnalyser() {
return this.analyser;
}
// 将Opus数据解码为PCM
async decodeOpusFrames() {
if (!this.opusDecoder) {
@@ -182,7 +191,8 @@ export class StreamingContext {
const currentTime = this.audioContext.currentTime;
const startTime = Math.max(this.scheduledEndTime, currentTime);
// 直接连接到输出
// 连接到分析器和输出
this.source.connect(this.analyser);
this.source.connect(this.audioContext.destination);
log(`调度播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / this.sampleRate).toFixed(2)}`, 'debug');
@@ -48,7 +48,9 @@ function renderMcpTools() {
const container = document.getElementById('mcpToolsContainer');
const countSpan = document.getElementById('mcpToolsCount');
countSpan.textContent = `${mcpTools.length} 个工具`;
if (countSpan) {
countSpan.textContent = `${mcpTools.length} 个工具`;
}
if (mcpTools.length === 0) {
container.innerHTML = '<div style="text-align: center; padding: 30px; color: #999;">暂无工具,点击下方按钮添加新工具</div>';
@@ -65,12 +67,10 @@ function renderMcpTools() {
<div class="mcp-tool-header">
<div class="mcp-tool-name">${tool.name}</div>
<div class="mcp-tool-actions">
<button onclick="window.mcpModule.editMcpTool(${index})"
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #2196f3; color: white; cursor: pointer; font-size: 12px;">
<button class="mcp-edit-btn" onclick="window.mcpModule.editMcpTool(${index})">
✏️ 编辑
</button>
<button onclick="window.mcpModule.deleteMcpTool(${index})"
style="padding: 4px 10px; border: none; border-radius: 4px; background-color: #f44336; color: white; cursor: pointer; font-size: 12px;">
<button class="mcp-delete-btn" onclick="window.mcpModule.deleteMcpTool(${index})">
🗑️ 删除
</button>
</div>
@@ -218,6 +218,9 @@ function setupMcpEventListeners() {
panel.classList.toggle('expanded');
toggleBtn.textContent = isExpanded ? '展开' : '收起';
});
// 确保面板默认展开
panel.classList.add('expanded');
addBtn.addEventListener('click', () => openMcpModal());
closeBtn.addEventListener('click', closeMcpModal);
@@ -1,6 +1,5 @@
// WebSocket消息处理模块
import { log } from '../../utils/logger.js';
import { addMessage } from '../../ui/dom-helper.js';
import { webSocketConnect } from './ota-connector.js';
import { getConfig, saveConnectionUrls } from '../../config/manager.js';
import { getAudioPlayer } from '../audio/player.js';
@@ -15,6 +14,7 @@ export class WebSocketHandler {
this.onRecordButtonStateChange = null;
this.onSessionStateChange = null;
this.onSessionEmotionChange = null;
this.onChatMessage = null; // 新增:聊天消息回调
this.currentSessionId = null;
this.isRemoteSpeaking = false;
}
@@ -79,30 +79,45 @@ export class WebSocketHandler {
log(`收到音频控制消息: ${JSON.stringify(message)}`, 'info');
} else if (message.type === 'stt') {
log(`识别结果: ${message.text}`, 'info');
addMessage(`${message.text}`, true);
// 使用新的聊天消息回调显示STT消息
if (this.onChatMessage && message.text) {
this.onChatMessage(message.text, true);
}
} else if (message.type === 'llm') {
log(`大模型回复: ${message.text}`, 'info');
// 使用新的聊天消息回调显示LLM回复
if (this.onChatMessage && message.text) {
this.onChatMessage(message.text, false);
}
// 如果包含表情,更新sessionStatus表情
// 如果包含表情,更新sessionStatus表情并触发Live2D动作
if (message.text && /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(message.text)) {
// 提取表情符号
const emojiMatch = message.text.match(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u);
if (emojiMatch && this.onSessionEmotionChange) {
this.onSessionEmotionChange(emojiMatch[0]);
}
// 触发Live2D情绪动作
if (message.emotion) {
console.log(`收到情绪消息: emotion=${message.emotion}, text=${message.text}`);
this.triggerLive2DEmotionAction(message.emotion);
}
}
// 只有当文本不仅仅是表情时,才添加到对话中
// 移除文本中的表情后检查是否还有内容
const textWithoutEmoji = message.text ? message.text.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim() : '';
if (textWithoutEmoji) {
addMessage(message.text);
if (textWithoutEmoji && this.onChatMessage) {
this.onChatMessage(message.text, false);
}
} else if (message.type === 'mcp') {
this.handleMCPMessage(message);
} else {
log(`未知消息类型: ${message.type}`, 'info');
addMessage(JSON.stringify(message, null, 2));
if (this.onChatMessage) {
this.onChatMessage(`未知消息类型: ${message.type}\n${JSON.stringify(message, null, 2)}`, false);
}
}
}
@@ -115,13 +130,26 @@ export class WebSocketHandler {
if (this.onSessionStateChange) {
this.onSessionStateChange(true);
}
// 启动Live2D说话动画
this.startLive2DTalking();
} else if (message.state === 'sentence_start') {
log(`服务器发送语音段: ${message.text}`, 'info');
if (message.text) {
addMessage(message.text);
this.ttsSentenceCount = (this.ttsSentenceCount || 0) + 1;
if (message.text && this.onChatMessage) {
this.onChatMessage(message.text, false);
}
// 确保动画在句子开始时运行
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager && !live2dManager.isTalking) {
this.startLive2DTalking();
}
} else if (message.state === 'sentence_end') {
log(`语音段结束: ${message.text}`, 'info');
// 句子结束时不清除动画,等待下一个句子或最终停止
} else if (message.state === 'stop') {
log('服务器语音传输结束,清空所有音频缓冲', 'info');
@@ -136,6 +164,57 @@ export class WebSocketHandler {
if (this.onSessionStateChange) {
this.onSessionStateChange(false);
}
// 延迟停止Live2D说话动画,确保所有句子都播放完毕
setTimeout(() => {
this.stopLive2DTalking();
this.ttsSentenceCount = 0; // 重置计数器
}, 1000); // 1秒延迟,确保所有句子都完成
}
}
// 启动Live2D说话动画
startLive2DTalking() {
try {
// 获取Live2D管理器实例
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager && live2dManager.live2dModel) {
// 使用音频播放器的分析器节点
live2dManager.startTalking();
log('Live2D说话动画已启动', 'info');
}
} catch (error) {
log(`启动Live2D说话动画失败: ${error.message}`, 'error');
}
}
// 停止Live2D说话动画
stopLive2DTalking() {
try {
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager) {
live2dManager.stopTalking();
log('Live2D说话动画已停止', 'info');
}
} catch (error) {
log(`停止Live2D说话动画失败: ${error.message}`, 'error');
}
}
// 初始化Live2D音频分析器
initializeLive2DAudioAnalyzer() {
try {
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager) {
// 初始化音频分析器(使用音频播放器的上下文)
if (live2dManager.initializeAudioAnalyzer()) {
log('Live2D音频分析器初始化完成,已连接到音频播放器', 'success');
} else {
log('Live2D音频分析器初始化失败,将使用模拟动画', 'warning');
}
}
} catch (error) {
log(`初始化Live2D音频分析器失败: ${error.message}`, 'error');
}
}
@@ -272,6 +351,9 @@ export class WebSocketHandler {
this.onSessionStateChange(false);
}
// 在WebSocket连接成功时初始化Live2D音频分析器
this.initializeLive2DAudioAnalyzer();
await this.sendHelloMessage();
};
@@ -304,9 +386,8 @@ export class WebSocketHandler {
}
} catch (error) {
log(`WebSocket消息处理错误: ${error.message}`, 'error');
if (typeof event.data === 'string') {
addMessage(event.data);
}
// 不再使用旧的addMessage函数,因为conversationDiv元素不存在
// 错误消息将通过其他方式显示
}
};
}
@@ -355,6 +436,24 @@ export class WebSocketHandler {
}
}
/**
* 触发Live2D情绪动作
* @param {string} emotion - 情绪名称
*/
triggerLive2DEmotionAction(emotion) {
try {
const live2dManager = window.chatApp?.live2dManager;
if (live2dManager && typeof live2dManager.triggerEmotionAction === 'function') {
live2dManager.triggerEmotionAction(emotion);
log(`触发Live2D情绪动作: ${emotion}`, 'info');
} else {
log(`无法触发Live2D情绪动作: Live2D管理器未找到或方法不可用`, 'warning');
}
} catch (error) {
log(`触发Live2D情绪动作失败: ${error.message}`, 'error');
}
}
// 获取WebSocket实例
getWebSocket() {
return this.websocket;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,501 @@
/**
* Live2D 管理器
* 负责 Live2D 模型的初始化、嘴部动画控制等功能
*/
class Live2DManager {
constructor() {
this.live2dApp = null;
this.live2dModel = null;
this.isTalking = false;
this.mouthAnimationId = null;
this.mouthParam = 'ParamMouthOpenY';
this.audioContext = null;
this.analyser = null;
this.dataArray = null;
this.lastEmotionActionTime = null; // 上次情绪触发动作的时间
// 情绪到动作的映射
this.emotionToActionMap = {
'happy': 'FlickUp', // 开心-向上轻扫动作
'laughing': 'FlickUp', // 大笑-向上轻扫动作
'funny': 'FlickUp', // 搞笑-向上轻扫动作
'sad': 'FlickDown', // 伤心-向下轻扫动作
'crying': 'FlickDown', // 哭泣-向下轻扫动作
'angry': 'Tap@Body', // 生气-身体点击动作
'surprised': 'Tap', // 惊讶-点击动作
'neutral': 'Flick', // 平常-轻扫动作
'default': 'Flick@Body' // 默认-身体轻扫动作
};
// 单/双击判定配置与状态
this._lastClickTime = 0;
this._lastClickPos = { x: 0, y: 0 };
this._singleClickTimer = null;
this._doubleClickMs = 280; // 双击时间阈值(ms)
this._doubleClickDist = 16; // 双击允许的最大位移(px)
// 滑动判定
this._pointerDown = false;
this._downPos = { x: 0, y: 0 };
this._downTime = 0;
this._downArea = 'Body';
this._movedBeyondClick = false;
this._swipeMinDist = 24; // 触发滑动的最小距离
}
/**
* 初始化 Live2D
*/
async initializeLive2D() {
try {
const canvas = document.getElementById('live2d-stage');
// 供内部使用
window.PIXI = PIXI;
this.live2dApp = new PIXI.Application({
view: canvas,
height: window.innerHeight,
width: window.innerWidth,
resolution: window.devicePixelRatio,
autoDensity: true,
antialias: true,
backgroundAlpha: 0,
});
// 加载 Live2D 模型 - 使用正确的相对路径
this.live2dModel = await PIXI.live2d.Live2DModel.from('../hiyori_pro_zh/runtime/hiyori_pro_t11.model3.json');
this.live2dApp.stage.addChild(this.live2dModel);
// 设置模型属性
this.live2dModel.scale.set(0.33);
this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
this.live2dModel.y = -50;
// 启用交互并监听点击命中(头部/身体等)
this.live2dModel.interactive = true;
this.live2dModel.on('doublehit', (args) => {
const area = Array.isArray(args) ? args[0] : args;
// 触发双击动作
if (area === 'Body') {
this.motion('Flick@Body');
} else if (area === 'Head' || area === 'Face') {
this.motion('Flick');
}
const app = window.chatApp;
const payload = JSON.stringify({ type: 'live2d', event: 'doublehit', area });
if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
app.dataChannel.send(payload);
}
});
this.live2dModel.on('singlehit', (args) => {
const area = Array.isArray(args) ? args[0] : args;
// 触发单击动作
if (area === 'Body') {
this.motion('Tap@Body');
} else if (area === 'Head' || area === 'Face') {
this.motion('Tap');
}
const app = window.chatApp;
const payload = JSON.stringify({ type: 'live2d', event: 'singlehit', area });
if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
app.dataChannel.send(payload);
}
});
this.live2dModel.on('swipe', (args) => {
const area = Array.isArray(args) ? args[0] : args;
const dir = Array.isArray(args) ? args[1] : undefined;
// 触发滑动动作
if (area === 'Body') {
if (dir === 'up') {
this.motion('FlickUp');
} else if (dir === 'down') {
this.motion('FlickDown');
}
} else if (area === 'Head' || area === 'Face') {
if (dir === 'up') {
this.motion('FlickUp');
} else if (dir === 'down') {
this.motion('FlickDown');
}
}
const app = window.chatApp;
const payload = JSON.stringify({ type: 'live2d', event: 'swipe', area, dir });
if (app && app.dataChannel && app.dataChannel.readyState === 'open') {
app.dataChannel.send(payload);
}
});
// 兜底:自定义"头部/身体"命中区域 + 单/双击/滑动区分
this.live2dModel.on('pointerdown', (event) => {
try {
const global = event.data.global;
const bounds = this.live2dModel.getBounds();
// 仅在点击落在模型可见范围内时判定
if (!bounds || !bounds.contains(global.x, global.y)) return;
const relX = (global.x - bounds.x) / (bounds.width || 1);
const relY = (global.y - bounds.y) / (bounds.height || 1);
let area = '';
// 经验阈值:模型可见矩形的上部 20% 视为"头部"区域
if (relX >= 0.4 && relX <= 0.6) {
if (relY <= 0.15) {
area = 'Head';
} else if (relY <= 0.23) {
area = 'Face';
} else {
area = 'Body';
}
}
if (area === '') {
return;
}
// 记录按下状态用于滑动判定
this._pointerDown = true;
this._downPos = { x: global.x, y: global.y };
this._downTime = performance.now();
this._downArea = area;
this._movedBeyondClick = false;
const now = performance.now();
const dt = now - (this._lastClickTime || 0);
const dx = global.x - (this._lastClickPos?.x || 0);
const dy = global.y - (this._lastClickPos?.y || 0);
const dist = Math.hypot(dx, dy);
// 命中确认:仅当点击在模型上时做单/双击判断
if (this._lastClickTime && dt <= this._doubleClickMs && dist <= this._doubleClickDist) {
// 判定为双击:取消待触发的单击事件
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
if (typeof this.live2dModel.emit === 'function') {
this.live2dModel.emit('doublehit', [area]);
}
this._lastClickTime = 0;
this._pointerDown = false; // 双击完成,重置状态
return;
}
// 可能是单击:记录并延迟确认
this._lastClickTime = now;
this._lastClickPos = { x: global.x, y: global.y };
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
this._singleClickTimer = setTimeout(() => {
// 若在等待期间发生了移动超过阈值,则不再当作单击
if (!this._movedBeyondClick && typeof this.live2dModel.emit === 'function') {
this.live2dModel.emit('singlehit', [area]);
}
this._singleClickTimer = null;
this._lastClickTime = 0;
}, this._doubleClickMs);
} catch (e) {
// 忽略自定义命中判断中的异常,避免影响主流程
}
});
// 指针移动:用于判定是否从"点击"升级为"滑动"
this.live2dModel.on('pointermove', (event) => {
try {
if (!this._pointerDown) return;
const global = event.data.global;
const dx = global.x - this._downPos.x;
const dy = global.y - this._downPos.y;
const dist = Math.hypot(dx, dy);
// 使用 _doubleClickDist 作为点击/滑动的判定阈值
if (dist > this._doubleClickDist) {
this._movedBeyondClick = true;
// 若已超出点击阈值,取消可能的单击触发
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
this._lastClickTime = 0;
}
} catch (e) {
// 忽略移动判定中的异常
}
});
// 指针抬起:确认是否为滑动
const handlePointerUp = (event) => {
try {
if (!this._pointerDown) return;
const global = (event && event.data && event.data.global) ? event.data.global : { x: this._downPos.x, y: this._downPos.y };
const dx = global.x - this._downPos.x;
const dy = global.y - this._downPos.y;
const dist = Math.hypot(dx, dy);
// 滑动:超过滑动最小距离则触发 swipe 事件(携带方向与区域)
if (this._movedBeyondClick && dist >= this._swipeMinDist) {
if (typeof this.live2dModel.emit === 'function') {
const dir = Math.abs(dx) >= Math.abs(dy)
? (dx > 0 ? 'right' : 'left')
: (dy > 0 ? 'down' : 'up');
this.live2dModel.emit('swipe', [this._downArea, dir]);
}
// 终止:不再让单击/双击触发
if (this._singleClickTimer) {
clearTimeout(this._singleClickTimer);
this._singleClickTimer = null;
}
this._lastClickTime = 0;
}
} catch (e) {
// 忽略抬起判定中的异常
}
finally {
this._pointerDown = false;
this._movedBeyondClick = false;
}
};
this.live2dModel.on('pointerup', handlePointerUp);
this.live2dModel.on('pointerupoutside', handlePointerUp);
// 添加窗口大小变化监听器,保持模型在Canvas中间和底部
window.addEventListener('resize', () => {
if (this.live2dModel) {
// 使用窗口实际尺寸重新计算模型位置
this.live2dModel.x = (window.innerWidth - this.live2dModel.width) * 0.5;
this.live2dModel.y = -50;
}
});
} catch (err) {
console.error('加载 Live2D 模型失败:', err);
}
}
/**
* 初始化音频分析器 - 使用音频播放器的分析器节点
*/
initializeAudioAnalyzer() {
try {
// 获取音频播放器实例
const audioPlayer = window.chatApp?.audioPlayer;
if (!audioPlayer) {
console.warn('音频播放器未初始化,无法获取分析器节点');
return false;
}
// 获取音频播放器的音频上下文
this.audioContext = audioPlayer.getAudioContext();
if (!this.audioContext) {
console.warn('无法获取音频播放器的音频上下文');
return false;
}
// 创建分析器节点
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
return true;
} catch (error) {
console.error('初始化音频分析器失败:', error);
return false;
}
}
/**
* 连接到音频播放器的输出节点
*/
connectToAudioPlayer() {
try {
// 获取音频播放器的流上下文
const audioPlayer = window.chatApp?.audioPlayer;
if (!audioPlayer || !audioPlayer.streamingContext) {
console.warn('音频播放器或流上下文未初始化');
return false;
}
// 获取音频播放器的流上下文
const streamingContext = audioPlayer.streamingContext;
// 获取分析器节点
const analyser = streamingContext.getAnalyser();
if (!analyser) {
console.warn('音频播放器尚未创建分析器节点,无法连接');
return false;
}
// 使用音频播放器的分析器节点
this.analyser = analyser;
this.dataArray = new Uint8Array(this.analyser.frequencyBinCount);
return true;
} catch (error) {
console.error('连接到音频播放器失败:', error);
return false;
}
}
/**
* 嘴部动画循环
*/
animateMouth() {
if (!this.isTalking) return;
if (!this.live2dModel) return;
const internal = this.live2dModel && this.live2dModel.internalModel;
if (internal && internal.coreModel) {
const coreModel = internal.coreModel;
// 获取音频分贝值
let mouthValue = 0;
let average = 0;
if (this.analyser && this.dataArray) {
this.analyser.getByteFrequencyData(this.dataArray);
average = this.dataArray.reduce((a, b) => a + b) / this.dataArray.length;
// 优化音量映射函数,使中等音量范围变化更明显
// 使用S形曲线函数,在中等音量范围有更好的响应
const normalizedVolume = average / 255;
// S形曲线:在0.3-0.7范围内有最大的斜率(变化最明显)
if (normalizedVolume < 0.3) {
// 低音量:缓慢增长
mouthValue = Math.pow(normalizedVolume / 0.3, 1.5) * 0.3;
} else if (normalizedVolume < 0.7) {
// 中等音量:线性增长,变化最明显
mouthValue = 0.3 + (normalizedVolume - 0.3) / 0.4 * 0.5;
} else {
// 高音量:缓慢接近最大值
mouthValue = 0.8 + Math.pow((normalizedVolume - 0.7) / 0.3, 1.2) * 0.2;
}
// 确保嘴部参数在0-1范围内
mouthValue = Math.min(Math.max(mouthValue, 0), 1);
}
coreModel.setParameterValueById(this.mouthParam, mouthValue);
coreModel.update();
}
this.mouthAnimationId = requestAnimationFrame(() => this.animateMouth());
}
/**
* 开始说话动画
*/
startTalking() {
if (this.isTalking || !this.live2dModel) return;
// 确保音频分析器已初始化
if (!this.analyser) {
if (!this.initializeAudioAnalyzer()) {
console.warn('音频分析器初始化失败,将使用模拟动画');
// 即使分析器初始化失败,也启动动画(使用模拟数据)
this.isTalking = true;
this.animateMouth();
return;
}
}
// 连接到音频播放器输出
if (!this.connectToAudioPlayer()) {
console.warn('无法连接到音频播放器输出,将使用模拟动画');
}
this.isTalking = true;
this.animateMouth();
}
/**
* 停止说话动画
*/
stopTalking() {
this.isTalking = false;
if (this.mouthAnimationId) {
cancelAnimationFrame(this.mouthAnimationId);
this.mouthAnimationId = null;
}
// 重置嘴部参数
if (this.live2dModel) {
const internal = this.live2dModel.internalModel;
if (internal && internal.coreModel) {
const coreModel = internal.coreModel;
coreModel.setParameterValueById(this.mouthParam, 0);
coreModel.update();
}
}
}
/**
* 基于情绪触发动作
* @param {string} emotion - 情绪名称
*/
triggerEmotionAction(emotion) {
if (!this.live2dModel) return;
// 添加冷却时间控制,避免过于频繁触发
const now = Date.now();
if (this.lastEmotionActionTime && now - this.lastEmotionActionTime < 5000) { // 5秒冷却时间
return;
}
// 根据情绪获取对应的动作
const action = this.emotionToActionMap[emotion] || this.emotionToActionMap['default'];
// 触发动作并记录时间
this.motion(action);
this.lastEmotionActionTime = now;
}
/**
* 触发模型动作(Motion)
* @param {string} name - 动作分组名称,如 'TapBody'、'FlickUp'、'Idle' 等
*/
motion(name) {
try {
if (!this.live2dModel) return;
this.live2dModel.motion(name);
} catch (error) {
console.error('触发动作失败:', error);
}
}
/**
* 清理资源
*/
destroy() {
this.stopTalking();
// 清理音频分析器
if (this.audioContext) {
this.audioContext.close();
this.audioContext = null;
}
this.analyser = null;
this.dataArray = null;
// 清理 Live2D 应用
if (this.live2dApp) {
this.live2dApp.destroy(true);
this.live2dApp = null;
}
this.live2dModel = null;
}
}
// 导出全局实例
window.Live2DManager = Live2DManager;
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+458 -251
View File
@@ -1,171 +1,490 @@
// UI控制模块
import { loadConfig, saveConfig } from '../config/manager.js';
import { getAudioPlayer } from '../core/audio/player.js';
import { loadConfig, saveConfig, getConfig } from '../config/manager.js';
import { getAudioRecorder } from '../core/audio/recorder.js';
import { getWebSocketHandler } from '../core/network/websocket.js';
import { getAudioPlayer } from '../core/audio/player.js';
// UI控制器类
export class UIController {
class UIController {
constructor() {
this.isEditing = false;
this.visualizerCanvas = null;
this.visualizerContext = null;
this.audioStatsTimer = null;
this.currentBackgroundIndex = 0;
this.backgroundImages = ['1.png', '2.png', '3.png'];
// 绑定方法
this.init = this.init.bind(this);
this.initEventListeners = this.initEventListeners.bind(this);
this.updateDialButton = this.updateDialButton.bind(this);
this.addChatMessage = this.addChatMessage.bind(this);
this.switchBackground = this.switchBackground.bind(this);
this.showModal = this.showModal.bind(this);
this.hideModal = this.hideModal.bind(this);
this.switchTab = this.switchTab.bind(this);
}
// 初始化
init() {
console.log('UIController init started');
this.visualizerCanvas = document.getElementById('audioVisualizer');
this.visualizerContext = this.visualizerCanvas.getContext('2d');
if (this.visualizerCanvas) {
this.visualizerContext = this.visualizerCanvas.getContext('2d');
this.initVisualizer();
}
// 检查连接按钮在初始化时是否存在
const connectBtn = document.getElementById('connectBtn');
console.log('connectBtn during init:', connectBtn);
this.initVisualizer();
this.initEventListeners();
this.startAudioStatsMonitor();
loadConfig();
// 设置录音器回调
const audioRecorder = getAudioRecorder();
audioRecorder.onRecordingStart = (seconds) => {
this.updateRecordButtonState(true, seconds);
};
// 初始化状态显示
this.updateConnectionUI(false);
this.updateDialButton(false);
console.log('UIController init completed');
}
// 初始化可视化器
initVisualizer() {
this.visualizerCanvas.width = this.visualizerCanvas.clientWidth;
this.visualizerCanvas.height = this.visualizerCanvas.clientHeight;
this.visualizerContext.fillStyle = '#fafafa';
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
if (this.visualizerCanvas) {
this.visualizerCanvas.width = this.visualizerCanvas.clientWidth;
this.visualizerCanvas.height = this.visualizerCanvas.clientHeight;
this.visualizerContext.fillStyle = '#fafafa';
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
}
}
// 更新状态显示
updateStatusDisplay(element, text) {
element.textContent = text;
element.removeAttribute('style');
element.classList.remove('connected');
if (text.includes('已连接')) {
element.classList.add('connected');
// 初始化事件监听器
initEventListeners() {
// 设置按钮
const settingsBtn = document.getElementById('settingsBtn');
if (settingsBtn) {
settingsBtn.addEventListener('click', () => {
this.showModal('settingsModal');
});
}
console.log('更新状态:', text, '类列表:', element.className, '样式属性:', element.getAttribute('style'));
// 背景切换按钮
const backgroundBtn = document.getElementById('backgroundBtn');
if (backgroundBtn) {
backgroundBtn.addEventListener('click', this.switchBackground);
}
// 拨号按钮
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.addEventListener('click', () => {
const wsHandler = getWebSocketHandler();
const isConnected = wsHandler.isConnected();
if (isConnected) {
wsHandler.disconnect();
this.updateDialButton(false);
} else {
// 检查OTA地址是否已填写
const otaUrlInput = document.getElementById('otaUrl');
if (!otaUrlInput || !otaUrlInput.value.trim()) {
// 如果OTA地址未填写,显示设置弹窗并切换到设备配置页
this.showModal('settingsModal');
this.switchTab('device');
this.addChatMessage('请先填写OTA服务器地址', false);
return;
}
// 执行连接操作
this.handleConnect();
}
});
}
// 录音按钮
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
recordBtn.addEventListener('click', () => {
const audioRecorder = getAudioRecorder();
if (audioRecorder.isRecording) {
audioRecorder.stop();
// 停止录音时移除录音样式
recordBtn.classList.remove('recording');
recordBtn.querySelector('.btn-text').textContent = '录音';
} else {
// 先更新按钮状态为录音中
recordBtn.classList.add('recording');
recordBtn.querySelector('.btn-text').textContent = '录音中';
// 延迟开始录音,确保按钮状态已更新
setTimeout(() => {
audioRecorder.start();
}, 100);
}
});
}
// 关闭按钮
const closeButtons = document.querySelectorAll('.close-btn');
closeButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
const modal = e.target.closest('.modal');
if (modal) {
this.hideModal(modal.id);
}
});
});
// 设置标签页切换
const tabBtns = document.querySelectorAll('.tab-btn');
tabBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// 点击模态框外部关闭
const modals = document.querySelectorAll('.modal');
modals.forEach(modal => {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideModal(modal.id);
}
});
});
// 保存配置按钮
const saveConfigBtn = document.getElementById('saveConfigBtn');
if (saveConfigBtn) {
saveConfigBtn.addEventListener('click', () => {
this.saveConfig();
});
}
// 添加MCP工具按钮
const addMCPToolBtn = document.getElementById('addMCPToolBtn');
if (addMCPToolBtn) {
addMCPToolBtn.addEventListener('click', () => {
this.addMCPTool();
});
}
// 连接按钮和取消按钮已被移除,功能已集成到拨号按钮中
}
// 更新连接状态UI
updateConnectionUI(isConnected) {
const connectionStatus = document.getElementById('connectionStatus');
const otaStatus = document.getElementById('otaStatus');
const connectButton = document.getElementById('connectButton');
const messageInput = document.getElementById('messageInput');
const sendTextButton = document.getElementById('sendTextButton');
const recordButton = document.getElementById('recordButton');
const statusDot = document.querySelector('.status-dot');
if (isConnected) {
this.updateStatusDisplay(connectionStatus, '● WS已连接');
this.updateStatusDisplay(otaStatus, '● OTA已连接');
connectButton.textContent = '断开';
messageInput.disabled = false;
sendTextButton.disabled = false;
recordButton.disabled = false;
} else {
this.updateStatusDisplay(connectionStatus, '● WS未连接');
this.updateStatusDisplay(otaStatus, '● OTA未连接');
connectButton.textContent = '连接';
messageInput.disabled = true;
sendTextButton.disabled = true;
recordButton.disabled = true;
// 断开连接时,会话状态变为离线
this.updateSessionStatus(null);
if (connectionStatus) {
if (isConnected) {
connectionStatus.textContent = '已连接';
if (statusDot) {
statusDot.className = 'status-dot status-connected';
}
} else {
connectionStatus.textContent = '离线';
if (statusDot) {
statusDot.className = 'status-dot status-disconnected';
}
}
}
}
// 更新拨号按钮状态
updateDialButton(isConnected) {
const dialBtn = document.getElementById('dialBtn');
const recordBtn = document.getElementById('recordBtn');
if (dialBtn) {
if (isConnected) {
dialBtn.classList.add('dial-active');
dialBtn.querySelector('.btn-text').textContent = '挂断';
// 更新拨号按钮图标为挂断图标
dialBtn.querySelector('svg').innerHTML = `
<path d="M12,9C10.4,9 9,10.4 9,12C9,13.6 10.4,15 12,15C13.6,15 15,13.6 15,12C15,10.4 13.6,9 12,9M12,17C9.2,17 7,14.8 7,12C7,9.2 9.2,7 12,7C14.8,7 17,9.2 17,12C17,14.8 14.8,17 12,17M12,4.5C7,4.5 2.7,7.6 1,12C2.7,16.4 7,19.5 12,19.5C17,19.5 21.3,16.4 23,12C21.3,7.6 17,4.5 12,4.5Z"/>
`;
} else {
dialBtn.classList.remove('dial-active');
dialBtn.querySelector('.btn-text').textContent = '拨号';
// 恢复拨号按钮图标
dialBtn.querySelector('svg').innerHTML = `
<path d="M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z"/>
`;
}
}
// 更新录音按钮状态
if (recordBtn) {
if (isConnected) {
recordBtn.disabled = false;
recordBtn.title = '开始录音';
// 确保录音按钮恢复到正常状态
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.classList.remove('recording');
} else {
recordBtn.disabled = true;
recordBtn.title = '请先连接服务器';
// 确保录音按钮恢复到正常状态
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.classList.remove('recording');
}
}
}
// 更新录音按钮状态
updateRecordButtonState(isRecording, seconds = 0) {
const recordButton = document.getElementById('recordButton');
if (isRecording) {
recordButton.textContent = `停止录音 ${seconds.toFixed(1)}`;
recordButton.classList.add('recording');
} else {
recordButton.textContent = '开始录音';
recordButton.classList.remove('recording');
}
recordButton.disabled = false;
}
// 更新会话状态UI
updateSessionStatus(isSpeaking) {
const sessionStatus = document.getElementById('sessionStatus');
if (!sessionStatus) return;
// 保留背景元素
const bgHtml = '<span id="sessionStatusBg" style="position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); transition: width 0.15s ease-out, background 0.3s ease; z-index: 0; border-radius: 20px;"></span>';
if (isSpeaking === null) {
// 离线状态
sessionStatus.innerHTML = bgHtml + '<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智离线中</span>';
sessionStatus.className = 'status offline';
} else if (isSpeaking) {
// 说话中
sessionStatus.innerHTML = bgHtml + '<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智说话中</span>';
sessionStatus.className = 'status speaking';
} else {
// 聆听中
sessionStatus.innerHTML = bgHtml + '<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智聆听中</span>';
sessionStatus.className = 'status listening';
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
if (isRecording) {
recordBtn.querySelector('.btn-text').textContent = `录音中 ${seconds.toFixed(1)}`;
recordBtn.classList.add('recording');
} else {
recordBtn.querySelector('.btn-text').textContent = '录音';
recordBtn.classList.remove('recording');
}
recordBtn.disabled = false;
}
}
// 更新会话表情
updateSessionEmotion(emoji) {
const sessionStatus = document.getElementById('sessionStatus');
if (!sessionStatus) return;
// 添加聊天消息
addChatMessage(content, isUser = false) {
const chatStream = document.getElementById('chatStream');
if (!chatStream) return;
// 获取当前文本内容,提取非表情部分
let currentText = sessionStatus.textContent;
// 移除现有的表情符号
currentText = currentText.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim();
const messageDiv = document.createElement('div');
messageDiv.className = `chat-message ${isUser ? 'user' : 'ai'}`;
messageDiv.innerHTML = `<div class="message-bubble">${content}</div>`;
chatStream.appendChild(messageDiv);
// 保留背景元素
const bgHtml = '<span id="sessionStatusBg" style="position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); transition: width 0.15s ease-out, background 0.3s ease; z-index: 0; border-radius: 20px;"></span>';
// 自动滚动到底部
chatStream.scrollTop = chatStream.scrollHeight;
}
// 使用 innerHTML 添加带样式的表情
sessionStatus.innerHTML = bgHtml + `<span style="position: relative; z-index: 1;"><span class="emoji-large">${emoji}</span> ${currentText}</span>`;
// 切换背景
switchBackground() {
this.currentBackgroundIndex = (this.currentBackgroundIndex + 1) % this.backgroundImages.length;
const backgroundContainer = document.querySelector('.background-container');
if (backgroundContainer) {
backgroundContainer.style.backgroundImage = `url('../images/${this.backgroundImages[this.currentBackgroundIndex]}')`;
}
}
// 显示模态框
showModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'flex';
}
}
// 隐藏模态框
hideModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.style.display = 'none';
}
}
// 切换标签页
switchTab(tabName) {
// 移除所有标签页的active类
const tabBtns = document.querySelectorAll('.tab-btn');
const tabContents = document.querySelectorAll('.tab-content');
tabBtns.forEach(btn => btn.classList.remove('active'));
tabContents.forEach(content => content.classList.remove('active'));
// 激活选中的标签页
const activeTabBtn = document.querySelector(`[data-tab="${tabName}"]`);
const activeTabContent = document.getElementById(`${tabName}Tab`);
if (activeTabBtn && activeTabContent) {
activeTabBtn.classList.add('active');
activeTabContent.classList.add('active');
}
}
// 保存配置
saveConfig() {
const config = {
serverUrl: document.getElementById('serverUrl').value,
serverPort: document.getElementById('serverPort').value,
audioDevice: document.getElementById('audioDevice').value,
audioSampleRate: document.getElementById('audioSampleRate').value,
audioChannels: document.getElementById('audioChannels').value
};
saveConfig(config);
this.hideModal('settingsModal');
// 显示保存成功消息
this.addChatMessage('配置已保存', false);
}
// 处理连接按钮点击
async handleConnect() {
console.log('handleConnect called');
// 确保切换到设备配置标签页
this.switchTab('device');
// 等待DOM更新
await new Promise(resolve => setTimeout(resolve, 50));
const otaUrlInput = document.getElementById('otaUrl');
console.log('otaUrl element:', otaUrlInput);
if (!otaUrlInput || !otaUrlInput.value) {
this.addChatMessage('请输入OTA服务器地址', false);
return;
}
const otaUrl = otaUrlInput.value;
console.log('otaUrl value:', otaUrl);
// 更新拨号按钮状态为连接中
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.classList.add('dial-active');
dialBtn.querySelector('.btn-text').textContent = '连接中...';
dialBtn.disabled = true;
}
// 显示连接中消息
this.addChatMessage('正在连接服务器...', false);
try {
// 获取配置信息
const config = getConfig();
// 导入OTA连接器
const { webSocketConnect } = await import('../core/network/ota-connector.js');
// 建立OTA连接
const websocket = await webSocketConnect(otaUrl, config);
if (websocket) {
// 获取WebSocket处理器
const wsHandler = getWebSocketHandler();
// 设置WebSocket连接
wsHandler.websocket = websocket;
// 设置连接状态回调
wsHandler.onConnectionStateChange = (isConnected) => {
this.updateConnectionUI(isConnected);
this.updateDialButton(isConnected);
};
// 设置聊天消息回调
wsHandler.onChatMessage = (text, isUser) => {
this.addChatMessage(text, isUser);
};
// 设置录音按钮状态回调
wsHandler.onRecordButtonStateChange = (isRecording) => {
const recordBtn = document.getElementById('recordBtn');
if (recordBtn) {
if (isRecording) {
recordBtn.classList.add('recording');
recordBtn.querySelector('.btn-text').textContent = '录音中';
} else {
recordBtn.classList.remove('recording');
recordBtn.querySelector('.btn-text').textContent = '录音';
}
}
};
// 连接成功
this.addChatMessage('OTA连接成功,正在建立WebSocket连接...', false);
// 更新拨号按钮状态
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.disabled = false;
dialBtn.querySelector('.btn-text').textContent = '挂断';
dialBtn.classList.add('dial-active');
}
this.hideModal('settingsModal');
// 自动尝试建立WebSocket连接
setTimeout(() => {
wsHandler.connect();
}, 1000);
} else {
throw new Error('OTA连接失败');
}
} catch (error) {
console.error('Connection error details:', {
message: error.message,
stack: error.stack,
name: error.name
});
// 显示错误消息
const errorMessage = error.message.includes('Cannot set properties of null')
? '连接失败:请刷新页面重试'
: `连接失败: ${error.message}`;
this.addChatMessage(errorMessage, false);
// 恢复拨号按钮状态
const dialBtn = document.getElementById('dialBtn');
if (dialBtn) {
dialBtn.disabled = false;
dialBtn.querySelector('.btn-text').textContent = '拨号';
dialBtn.classList.remove('dial-active');
console.log('Dial button state restored successfully');
}
}
}
// 添加MCP工具
addMCPTool() {
const mcpToolsList = document.getElementById('mcpToolsList');
if (!mcpToolsList) return;
const toolId = `mcp-tool-${Date.now()}`;
const toolDiv = document.createElement('div');
toolDiv.className = 'properties-container';
toolDiv.innerHTML = `
<div class="property-item">
<input type="text" placeholder="工具名称" value="新工具">
<input type="text" placeholder="工具描述" value="工具描述">
<button class="remove-property" onclick="uiController.removeMCPTool('${toolId}')">删除</button>
</div>
`;
mcpToolsList.appendChild(toolDiv);
}
// 移除MCP工具
removeMCPTool(toolId) {
const toolElement = document.getElementById(toolId);
if (toolElement) {
toolElement.remove();
}
}
// 更新音频统计信息
updateAudioStats() {
const audioPlayer = getAudioPlayer();
if (!audioPlayer) return;
const stats = audioPlayer.getAudioStats();
const sessionStatus = document.getElementById('sessionStatus');
const sessionStatusBg = document.getElementById('sessionStatusBg');
// 只在说话状态下显示背景进度
if (sessionStatus && sessionStatus.classList.contains('speaking') && sessionStatusBg) {
if (stats.pendingPlay > 0) {
// 计算进度:5包=50%10包及以上=100%
let percentage;
if (stats.pendingPlay >= 10) {
percentage = 100;
} else {
percentage = (stats.pendingPlay / 10) * 100;
}
sessionStatusBg.style.width = `${percentage}%`;
// 根据缓冲量改变背景颜色
if (stats.pendingPlay < 5) {
// 缓冲不足:橙红色半透明
sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(255, 152, 0, 0.25), rgba(255, 87, 34, 0.25))';
} else if (stats.pendingPlay < 10) {
// 一般:黄绿色半透明
sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(205, 220, 57, 0.25), rgba(76, 175, 80, 0.25))';
} else {
// 充足:绿蓝色半透明
sessionStatusBg.style.background = 'linear-gradient(90deg, rgba(76, 175, 80, 0.25), rgba(33, 150, 243, 0.25))';
}
} else {
// 没有缓冲,隐藏背景
sessionStatusBg.style.width = '0%';
}
} else {
// 非说话状态,隐藏背景
if (sessionStatusBg) {
sessionStatusBg.style.width = '0%';
}
}
// 这里可以添加音频统计的UI更新逻辑
}
// 启动音频统计监控
@@ -186,6 +505,8 @@ export class UIController {
// 绘制音频可视化效果
drawVisualizer(dataArray) {
if (!this.visualizerContext || !this.visualizerCanvas) return;
this.visualizerContext.fillStyle = '#fafafa';
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
@@ -197,146 +518,32 @@ export class UIController {
barHeight = dataArray[i] / 2;
// 创建渐变色:从紫色到蓝色到青色
const hue = 200 + (barHeight / this.visualizerCanvas.height) * 60; // 200-260度,从青色到紫色
const saturation = 80 + (barHeight / this.visualizerCanvas.height) * 20; // 饱和度 80-100%
const lightness = 45 + (barHeight / this.visualizerCanvas.height) * 15; // 亮度 45-60%
const gradient = this.visualizerContext.createLinearGradient(0, 0, 0, this.visualizerCanvas.height);
gradient.addColorStop(0, '#8e44ad');
gradient.addColorStop(0.5, '#3498db');
gradient.addColorStop(1, '#1abc9c');
this.visualizerContext.fillStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
this.visualizerContext.fillStyle = gradient;
this.visualizerContext.fillRect(x, this.visualizerCanvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
// 初始化事件监听器
initEventListeners() {
const wsHandler = getWebSocketHandler();
const audioRecorder = getAudioRecorder();
// 更新会话状态UI
updateSessionStatus(isSpeaking) {
// 这里可以添加会话状态的UI更新逻辑
// 例如:更新Live2D角色的表情或状态指示器
}
// 设置WebSocket回调
wsHandler.onConnectionStateChange = (isConnected) => {
this.updateConnectionUI(isConnected);
};
wsHandler.onRecordButtonStateChange = (isRecording) => {
this.updateRecordButtonState(isRecording);
};
wsHandler.onSessionStateChange = (isSpeaking) => {
this.updateSessionStatus(isSpeaking);
};
wsHandler.onSessionEmotionChange = (emoji) => {
this.updateSessionEmotion(emoji);
};
// 设置录音器回调
audioRecorder.onRecordingStart = (seconds) => {
this.updateRecordButtonState(true, seconds);
};
audioRecorder.onRecordingStop = () => {
this.updateRecordButtonState(false);
};
audioRecorder.onVisualizerUpdate = (dataArray) => {
this.drawVisualizer(dataArray);
};
// 连接按钮
const connectButton = document.getElementById('connectButton');
let isConnecting = false;
const handleConnect = async () => {
if (isConnecting) return;
if (wsHandler.isConnected()) {
wsHandler.disconnect();
} else {
isConnecting = true;
await wsHandler.connect();
isConnecting = false;
}
};
connectButton.addEventListener('click', handleConnect);
// 设备配置面板编辑/确定切换
const toggleButton = document.getElementById('toggleConfig');
const deviceMacInput = document.getElementById('deviceMac');
const deviceNameInput = document.getElementById('deviceName');
const clientIdInput = document.getElementById('clientId');
toggleButton.addEventListener('click', () => {
this.isEditing = !this.isEditing;
deviceMacInput.disabled = !this.isEditing;
deviceNameInput.disabled = !this.isEditing;
clientIdInput.disabled = !this.isEditing;
toggleButton.textContent = this.isEditing ? '确定' : '编辑';
if (!this.isEditing) {
saveConfig();
}
});
// 标签页切换
const tabs = document.querySelectorAll('.tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
const tabContent = document.getElementById(`${tab.dataset.tab}Tab`);
tabContent.classList.add('active');
if (tab.dataset.tab === 'voice') {
setTimeout(() => {
this.initVisualizer();
}, 50);
}
});
});
// 发送文本消息
const messageInput = document.getElementById('messageInput');
const sendTextButton = document.getElementById('sendTextButton');
const sendMessage = () => {
const message = messageInput.value.trim();
if (message && wsHandler.sendTextMessage(message)) {
messageInput.value = '';
}
};
sendTextButton.addEventListener('click', sendMessage);
messageInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendMessage();
});
// 录音按钮
const recordButton = document.getElementById('recordButton');
recordButton.addEventListener('click', () => {
if (audioRecorder.isRecording) {
audioRecorder.stop();
} else {
audioRecorder.start();
}
});
// 窗口大小变化
window.addEventListener('resize', () => this.initVisualizer());
// 更新会话表情
updateSessionEmotion(emoji) {
// 这里可以添加表情更新的逻辑
// 例如:在状态指示器中显示表情
}
}
// 创建
let uiControllerInstance = null;
// 创建全局实
export const uiController = new UIController();
export function getUIController() {
if (!uiControllerInstance) {
uiControllerInstance = new UIController();
}
return uiControllerInstance;
}
// 导出类供其他模块使用
export { UIController };
+9 -6
View File
@@ -13,12 +13,15 @@ let visualizerCanvas = document.getElementById('audioVisualizer');
// ota 是否连接成功,修改成对应的样式
export function otaStatusStyle(flan) {
if (flan) {
document.getElementById('otaStatus').textContent = 'OTA已连接';
document.getElementById('otaStatus').style.color = 'green';
} else {
document.getElementById('otaStatus').textContent = 'OTA未连接';
document.getElementById('otaStatus').style.color = 'red';
const otaStatusElement = document.getElementById('otaStatus');
if (otaStatusElement) {
if (flan) {
otaStatusElement.textContent = 'OTA已连接';
otaStatusElement.style.color = 'green';
} else {
otaStatusElement.textContent = 'OTA未连接';
otaStatusElement.style.color = 'red';
}
}
}
+9 -3
View File
@@ -1,6 +1,3 @@
import { getLogContainer } from '../ui/dom-helper.js'
const logContainer = getLogContainer();
// 日志记录函数
export function log(message, type = 'info') {
// 将消息按换行符分割成多行
@@ -8,6 +5,15 @@ export function log(message, type = 'info') {
const now = new Date();
// const timestamp = `[${now.toLocaleTimeString()}] `;
const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `;
// 检查是否存在日志容器
const logContainer = document.getElementById('logContainer');
if (!logContainer) {
// 如果日志容器不存在,只输出到控制台
console.log(`[${type.toUpperCase()}] ${message}`);
return;
}
// 为每一行创建日志条目
lines.forEach((line, index) => {
const logEntry = document.createElement('div');
+175 -152
View File
@@ -10,6 +10,11 @@
// 检测是否使用file://协议打开
if (window.location.protocol === 'file:') {
document.addEventListener('DOMContentLoaded', function () {
// 创建背景模糊遮罩
const overlayDiv = document.createElement('div');
overlayDiv.className = 'file-protocol-overlay';
document.body.appendChild(overlayDiv);
// 创建警告框
const warningDiv = document.createElement('div');
warningDiv.id = 'fileProtocolWarning';
@@ -32,198 +37,216 @@
</head>
<body>
<!-- 背景容器 -->
<div class="background-container">
<div class="background-overlay"></div>
</div>
<!-- 主容器 -->
<div class="container">
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
正在加载Opus库...
<!-- 连接状态指示器 - 页面顶部中部 -->
<div class="connection-status-top">
<div class="status-indicator">
<span class="status-dot status-disconnected"></span>
<span id="connectionStatus">离线</span>
</div>
</div>
<!-- 两栏布局:设备配置和连接信息 -->
<div class="two-column-layout">
<!-- 设备配置面板 -->
<div class="section">
<h2>
设备配置
<button class="toggle-button" id="toggleConfig">编辑</button>
</h2>
<div class="config-panel" id="configPanel">
<div class="control-panel">
<div class="config-row">
<div class="config-item">
<label for="deviceMac">设备MAC:</label>
<input type="text" id="deviceMac" placeholder="device-id" disabled>
<!-- Live2D Canvas 容器 -->
<canvas id="live2d-stage"></canvas>
<!-- 聊天消息流(弹幕样式) -->
<div class="chat-stream" id="chatStream">
<!-- 聊天消息将动态插入这里 -->
</div>
<!-- 底部控制栏 -->
<div class="control-bar">
<button class="control-btn" id="settingsBtn" title="设置">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5A3.5 3.5 0 0 1 15.5 12A3.5 3.5 0 0 1 12 15.5M19.43 12.97C19.47 12.65 19.5 12.33 19.5 12C19.5 11.67 19.47 11.34 19.43 11L21.54 9.37C21.73 9.22 21.78 8.95 21.66 8.73L19.66 5.27C19.54 5.05 19.27 4.96 19.05 5.05L16.56 6.05C16.04 5.66 15.5 5.32 14.87 5.07L14.5 2.42C14.46 2.18 14.25 2 14 2H10C9.75 2 9.54 2.18 9.5 2.42L9.13 5.07C8.5 5.32 7.96 5.66 7.44 6.05L4.95 5.05C4.73 4.96 4.46 5.05 4.34 5.27L2.34 8.73C2.22 8.95 2.27 9.22 2.46 9.37L4.57 11C4.53 11.34 4.5 11.67 4.5 12C4.5 12.33 4.53 12.65 4.57 12.97L2.46 14.63C2.27 14.78 2.22 15.05 2.34 15.27L4.34 18.73C4.46 18.95 4.73 19.03 4.95 18.95L7.44 17.94C7.96 18.34 8.5 18.68 9.13 18.93L9.5 21.58C9.54 21.82 9.75 22 10 22H14C14.25 22 14.46 21.82 14.5 21.58L14.87 18.93C15.5 18.68 16.04 18.34 16.56 17.94L19.05 18.95C19.27 19.03 19.54 18.95 19.66 18.73L21.66 15.27C21.78 15.05 21.73 14.78 21.54 14.63L19.43 12.97Z" />
</svg>
<span class="btn-text">设置</span>
</button>
<button class="control-btn" id="backgroundBtn" title="切换背景">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15V18M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
</svg>
<span class="btn-text">背景</span>
</button>
<button class="control-btn dial-btn" id="dialBtn" title="拨号">
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M6.62,10.79C8.06,13.62 10.38,15.94 13.21,17.38L15.41,15.18C15.69,14.9 16.08,14.82 16.43,14.93C17.55,15.3 18.75,15.5 20,15.5A1,1 0 0,1 21,16.5V20A1,1 0 0,1 20,21A17,17 0 0,1 3,4A1,1 0 0,1 4,3H7.5A1,1 0 0,1 8.5,4C8.5,5.25 8.7,6.45 9.07,7.57C9.18,7.92 9.1,8.31 8.82,8.59L6.62,10.79Z" />
</svg>
<span class="btn-text">拨号</span>
</button>
<button class="control-btn" id="recordBtn" title="开始录音" disabled>
<svg class="btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" />
</svg>
<span class="btn-text">录音</span>
</button>
</div>
</div>
<!-- 设置弹窗 -->
<div class="modal" id="settingsModal">
<div class="modal-content settings-modal">
<div class="modal-header">
<h2>设置</h2>
<button class="close-btn" id="closeSettingsBtn">&times;</button>
</div>
<div class="modal-body">
<div class="settings-tabs">
<button class="tab-btn active" data-tab="device">设备配置</button>
<button class="tab-btn" data-tab="mcp">MCP工具</button>
</div>
<div class="tab-content active" id="deviceTab">
<!-- 设备配置面板 -->
<div class="config-panel">
<div class="control-panel">
<div class="config-row">
<div class="config-item">
<label for="deviceMac">设备MAC:</label>
<input type="text" id="deviceMac" placeholder="device-id" readonly>
</div>
</div>
<div class="config-item">
<label for="clientId">客户端ID:</label>
<input type="text" id="clientId" value="web_test_client" placeholder="client-id"
disabled>
<div class="config-row">
<div class="config-item">
<label for="clientId">客户端ID:</label>
<input type="text" id="clientId" value="web_test_client" placeholder="client-id">
</div>
<div class="config-item">
<label for="deviceName">设备名称:</label>
<input type="text" id="deviceName" value="Web测试设备" placeholder="deviceName">
</div>
</div>
</div>
<div class="config-row">
<div class="config-item">
<label for="deviceName">设备名称:</label>
<input type="text" id="deviceName" value="Web测试设备" placeholder="deviceName" disabled>
</div>
<!-- 连接信息面板 -->
<div class="connection-controls">
<div class="input-group">
<label for="otaUrl">OTA服务器地址:</label>
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/"
placeholder="OTA服务器地址,如:http://127.0.0.1:8002/xiaozhi/ota/" />
</div>
<div class="input-group">
<label for="serverUrl">WebSocket服务器地址:</label>
<input type="text" id="serverUrl" value="" readonly disabled
placeholder="填写OTA地址后,点击拨号按钮自动连接" />
</div>
</div>
</div>
<div class="tab-content" id="mcpTab">
<!-- MCP 工具管理区域 -->
<div class="mcp-tools-container">
<div class="mcp-tools-header">
<h3>MCP 工具管理</h3>
<button class="btn-secondary" id="toggleMcpTools">
收起
</button>
</div>
<div class="mcp-tools-panel" id="mcpToolsPanel">
<div class="mcp-tools-list" id="mcpToolsContainer">
<!-- 工具列表将动态插入这里 -->
</div>
<div class="mcp-actions">
<button class="btn-primary" id="addMcpToolBtn">
➕ 添加新工具
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 连接信息面板 -->
<div class="section">
<h2>
连接信息
<span class="connection-status">
<span id="otaStatus" class="status">● OTA未连接</span>
<span id="connectionStatus" class="status">● WS未连接</span>
</span>
<button class="connect-button" id="connectButton">
<span></span>
连接
</button>
</h2>
<div class="connection-controls">
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/"
placeholder="OTA服务器地址,如:http://127.0.0.1:8002/xiaozhi/ota/" />
<input type="text" id="serverUrl" value="" readonly disabled placeholder="点击连接按钮后,自动从OTA接口获取" />
</div>
</div>
</div>
</div>
<div class="section">
<div class="tabs">
<button class="tab active" data-tab="text">文本消息</button>
<button class="tab" data-tab="voice">语音消息</button>
<!-- MCP 工具编辑模态框 -->
<div id="mcpToolModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="mcpModalTitle">添加工具</h2>
<button class="close-btn" id="closeMcpModalBtn">&times;</button>
</div>
<div class="tab-content active" id="textTab">
<div class="message-input">
<input type="text" id="messageInput" placeholder="输入消息..." disabled>
<button id="sendTextButton" disabled>发送</button>
</div>
</div>
<div class="tab-content" id="voiceTab">
<div class="audio-controls">
<button id="recordButton" class="record-button" disabled>开始录音</button>
</div>
<canvas id="audioVisualizer" class="audio-visualizer"></canvas>
</div>
<div style="margin: -10px 0px 10px 0px;">
<span class="connection-status llm-emoji">
<span id="sessionStatus" class="status offline" style="position: relative; display: inline-block;">
<span id="sessionStatusBg"
style="position: absolute; left: 0; top: 0; bottom: 0; width: 0%; background: linear-gradient(90deg, rgba(76, 175, 80, 0.2), rgba(33, 150, 243, 0.2)); transition: width 0.15s ease-out, background 0.3s ease; z-index: 0; border-radius: 20px;"></span>
<span style="position: relative; z-index: 1;"><span class="emoji-large">😶</span> 小智离线中</span>
</span>
</span>
</div>
<div class="flex-container">
<div id="conversation" class="conversation"></div>
<div id="logContainer">
<div class="log-entry log-info">准备就绪,请连接服务器开始测试...</div>
</div>
</div>
</div>
<!-- MCP 工具管理区域 -->
<div class="section">
<h2>
MCP 工具管理
<span class="connection-status">
<span id="mcpToolsCount">0 个工具</span>
</span>
<button class="toggle-button" id="toggleMcpTools">展开</button>
</h2>
<div class="config-panel" id="mcpToolsPanel">
<div id="mcpToolsContainer" class="mcp-tools-container">
<!-- 工具列表将动态插入这里 -->
</div>
<div style="text-align: center; padding: 15px 0;">
<button class="btn" id="addMcpToolBtn" style="background-color: #4caf50;">
➕ 添加新工具
</button>
</div>
</div>
</div>
<!-- MCP 工具编辑模态框 -->
<div id="mcpToolModal"
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 9999; overflow-y: auto;">
<div
style="background-color: white; border-radius: 10px; padding: 25px; width: 90%; max-width: 700px; margin: 50px auto; max-height: 85vh; overflow-y: auto;">
<div
style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e0e0e0;">
<h2 id="mcpModalTitle" style="font-size: 20px; font-weight: bold; color: #333; margin: 0;">添加工具</h2>
<button id="closeMcpModalBtn"
style="background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 30px; height: 30px;">&times;</button>
</div>
<div class="modal-body">
<div id="mcpErrorContainer"></div>
<form id="mcpToolForm">
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具名称
*</label>
<input type="text" id="mcpToolName" placeholder="例如: self.get_device_status" required
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px;">
<div class="input-group">
<label for="mcpToolName">工具名称 *</label>
<input type="text" id="mcpToolName" placeholder="例如: self.get_device_status" required>
</div>
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">工具描述
*</label>
<textarea id="mcpToolDescription" placeholder="详细描述工具的功能和使用场景..." required
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; min-height: 80px; resize: vertical;"></textarea>
<div class="input-group">
<label for="mcpToolDescription">工具描述 *</label>
<textarea id="mcpToolDescription" placeholder="详细描述工具的功能和使用场景..." required></textarea>
</div>
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">输入参数</label>
<div
style="background-color: #f9f9f9; border: 1px solid #ddd; border-radius: 5px; padding: 15px;">
<div class="input-group">
<label>输入参数</label>
<div class="properties-container">
<div id="mcpPropertiesContainer">
<div style="text-align: center; padding: 20px; color: #999; font-size: 14px;">
暂无参数,点击下方按钮添加参数</div>
暂无参数,点击下方按钮添加参数
</div>
</div>
<button type="button" id="addMcpPropertyBtn"
style="width: 100%; margin-top: 10px; padding: 8px 15px; border: none; border-radius: 5px; background-color: #2196f3; color: white; cursor: pointer; font-size: 14px;">
<button type="button" class="btn-secondary" id="addMcpPropertyBtn">
➕ 添加参数
</button>
</div>
</div>
<div style="margin-bottom: 20px;">
<label
style="display: block; margin-bottom: 8px; font-weight: 500; color: #333; font-size: 14px;">
<div class="input-group">
<label for="mcpMockResponse">
模拟返回结果 (JSON 格式,可选)
<span style="font-size: 12px; color: #999; font-weight: normal;">- 留空则返回默认成功消息</span>
</label>
<textarea id="mcpMockResponse" placeholder='{"success": true, "data": "执行成功"}'
style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 13px; font-family: 'Courier New', monospace; min-height: 100px; resize: vertical;"></textarea>
<div style="font-size: 12px; color: #666; margin-top: 4px;">
💡 提示:如果设置了模拟返回结果,工具调用时将返回这个 JSON 对象
</div>
<textarea id="mcpMockResponse" placeholder='{"success": true, "data": "执行成功"}'></textarea>
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 25px;">
<button type="button" id="cancelMcpBtn"
style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #9e9e9e; color: white; cursor: pointer; font-size: 14px;">取消</button>
<button type="submit"
style="padding: 8px 15px; border: none; border-radius: 5px; background-color: #4caf50; color: white; cursor: pointer; font-size: 14px;">保存</button>
<div class="modal-actions">
<button type="button" class="btn-secondary" id="cancelMcpBtn">取消</button>
<button type="submit" class="btn-primary">保存</button>
</div>
</form>
</div>
</div>
</div>
<!-- PIXI.js 2D渲染引擎 -->
<script src="js/live2d/pixi.js"></script>
<!-- Live2D Cubism 4.0 SDK -->
<script src="js/live2d/live2dcubismcore.min.js"></script>
<script src="js/live2d/cubism4.min.js"></script>
<!-- Live2D 管理器 -->
<script src="js/live2d/live2d.js"></script>
<!-- Opus解码库 -->
<script src="js/utils/libopus.js"></script>
<!-- 主应用入口 -->
<script type="module" src="js/app.js"></script>
<!-- 全局错误处理 -->
<script>
// 添加全局错误处理
window.addEventListener('error', function (event) {
console.error('全局错误:', event.error);
});
// 添加未处理的Promise拒绝处理
window.addEventListener('unhandledrejection', function (event) {
console.error('未处理的Promise拒绝:', event.reason);
});
</script>
</body>
</html>