450 lines
17 KiB
GDScript
450 lines
17 KiB
GDScript
extends Control
|
|
|
|
# ============================================================================
|
|
# CafeCompanionRecruitmentPanel.gd - 咖啡店陪伴机器人招聘登记面板
|
|
# ============================================================================
|
|
# 点击咖啡馆吧台鲸鱼标识后打开,玩家提交陪伴 Agent 代理配置,
|
|
# 后端登记为咖啡店陪伴服务点上的被雇佣角色。
|
|
# ============================================================================
|
|
|
|
@onready var recruitmentFrame: PanelContainer = %CafeRecruitmentFrame
|
|
@onready var titleLabel: Label = %TitleLabel
|
|
@onready var subtitleLabel: Label = %SubtitleLabel
|
|
@onready var closeButton: Button = %CloseButton
|
|
@onready var servicePointOption: OptionButton = %ServicePointOption
|
|
@onready var employmentDurationOption: OptionButton = %EmploymentDurationOption
|
|
@onready var personaNameInput: LineEdit = %PersonaNameInput
|
|
@onready var protocolOption: OptionButton = %ProtocolOption
|
|
@onready var baseUrlLabel: Label = %BaseUrlLabel
|
|
@onready var baseUrlInput: LineEdit = %BaseUrlInput
|
|
@onready var tokenInput: LineEdit = %TokenInput
|
|
@onready var modelInput: LineEdit = %ModelInput
|
|
@onready var fetchModelsButton: Button = %FetchModelsButton
|
|
@onready var modelOption: OptionButton = %ModelOption
|
|
@onready var personaPromptInput: TextEdit = %PersonaPromptInput
|
|
@onready var welcomeMessageInput: LineEdit = %WelcomeMessageInput
|
|
@onready var enabledCheckBox: CheckBox = %EnabledCheckBox
|
|
@onready var submitButton: Button = %SubmitButton
|
|
@onready var statusLabel: Label = %StatusLabel
|
|
|
|
var _servicePoints: Array = []
|
|
var _isSubmitting: bool = false
|
|
var _isFetchingModels: bool = false
|
|
|
|
func _ready() -> void:
|
|
add_to_group("whaletown_escape_dismissible")
|
|
recruitmentFrame.visible = false
|
|
_setup_protocol_options()
|
|
_setup_employment_duration_options()
|
|
closeButton.pressed.connect(_on_close_pressed)
|
|
submitButton.pressed.connect(_on_submit_pressed)
|
|
fetchModelsButton.pressed.connect(_on_fetch_models_pressed)
|
|
protocolOption.item_selected.connect(_on_protocol_selected)
|
|
modelOption.item_selected.connect(_on_model_option_selected)
|
|
_connect_events()
|
|
|
|
func _exit_tree() -> void:
|
|
var eventSystem := get_node_or_null("/root/EventSystem")
|
|
if eventSystem == null:
|
|
return
|
|
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, _on_recruitment_selected, self)
|
|
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, _on_service_points_ready, self)
|
|
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_agent_registered, self)
|
|
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_MODELS_READY, _on_models_ready, self)
|
|
eventSystem.call("disconnect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
|
|
|
|
func _connect_events() -> void:
|
|
var eventSystem := get_node_or_null("/root/EventSystem")
|
|
if eventSystem == null:
|
|
push_warning("CafeCompanionRecruitmentPanel: EventSystem autoload is not available.")
|
|
return
|
|
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_RECRUITMENT_SELECTED, _on_recruitment_selected, self)
|
|
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_SERVICE_POINTS_READY, _on_service_points_ready, self)
|
|
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_AGENT_REGISTERED, _on_agent_registered, self)
|
|
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_MODELS_READY, _on_models_ready, self)
|
|
eventSystem.call("connect_event", EventNames.CAFE_COMPANION_ERROR_OCCURRED, _on_companion_error, self)
|
|
|
|
func _on_recruitment_selected(data: Dictionary) -> void:
|
|
titleLabel.text = str(data.get("title", "咖啡店陪伴招聘"))
|
|
subtitleLabel.text = "鲸鱼咖啡馆 · 吧台招聘终端"
|
|
recruitmentFrame.visible = true
|
|
_isSubmitting = false
|
|
_isFetchingModels = false
|
|
submitButton.disabled = false
|
|
fetchModelsButton.disabled = false
|
|
_apply_protocol_defaults(true)
|
|
_set_status("正在读取陪伴位...", false)
|
|
_request_service_points()
|
|
|
|
func _request_service_points() -> void:
|
|
var manager := _get_cafe_companion_manager()
|
|
if manager == null or not manager.has_method("get_service_points"):
|
|
_set_status("CafeCompanionManager 未加载", true)
|
|
return
|
|
manager.call("get_service_points", true)
|
|
|
|
func _on_service_points_ready(data: Dictionary) -> void:
|
|
if not recruitmentFrame.visible:
|
|
return
|
|
var servicePointsVariant: Variant = data.get("service_points", [])
|
|
_servicePoints = servicePointsVariant if servicePointsVariant is Array else []
|
|
_render_service_points()
|
|
|
|
func _on_agent_registered(data: Dictionary) -> void:
|
|
if not recruitmentFrame.visible:
|
|
return
|
|
_isSubmitting = false
|
|
submitButton.disabled = false
|
|
fetchModelsButton.disabled = false
|
|
tokenInput.clear()
|
|
|
|
var companionVariant: Variant = data.get("companion", {})
|
|
if companionVariant is Dictionary:
|
|
var companion: Dictionary = companionVariant
|
|
_set_status("%s 已登记到 %s" % [
|
|
str(companion.get("persona_name", "陪伴机器人")),
|
|
_format_service_point_label(str(companion.get("service_point_id", ""))),
|
|
], false)
|
|
else:
|
|
_set_status("咖啡店陪伴机器人登记成功", false)
|
|
recruitmentFrame.visible = false
|
|
release_focus()
|
|
|
|
func _on_companion_error(data: Dictionary) -> void:
|
|
if not recruitmentFrame.visible:
|
|
return
|
|
_isSubmitting = false
|
|
_isFetchingModels = false
|
|
submitButton.disabled = false
|
|
fetchModelsButton.disabled = false
|
|
_set_status(str(data.get("message", "咖啡店招聘暂时不可用")), true)
|
|
|
|
func _render_service_points() -> void:
|
|
servicePointOption.clear()
|
|
var selectedIndex := -1
|
|
for pointVariant in _servicePoints:
|
|
if not (pointVariant is Dictionary):
|
|
continue
|
|
var point: Dictionary = pointVariant
|
|
var pointId := str(point.get("id", "")).strip_edges()
|
|
if pointId.is_empty():
|
|
continue
|
|
var index := servicePointOption.get_item_count()
|
|
servicePointOption.add_item(_format_service_point_option(point))
|
|
servicePointOption.set_item_metadata(index, pointId)
|
|
if selectedIndex < 0 and not _point_has_companion(point):
|
|
selectedIndex = index
|
|
|
|
if servicePointOption.get_item_count() <= 0:
|
|
_set_status("当前没有开放的咖啡店陪伴位", true)
|
|
submitButton.disabled = true
|
|
return
|
|
|
|
servicePointOption.select(selectedIndex if selectedIndex >= 0 else 0)
|
|
submitButton.disabled = _isSubmitting
|
|
fetchModelsButton.disabled = _isSubmitting
|
|
_set_status("填写人设和代理配置后可登记", false)
|
|
|
|
func _format_service_point_option(point: Dictionary) -> String:
|
|
var pointId := str(point.get("id", "")).strip_edges()
|
|
var label := _format_service_point_label(pointId)
|
|
var companionVariant: Variant = point.get("companion", null)
|
|
if companionVariant is Dictionary:
|
|
var companion: Dictionary = companionVariant
|
|
var personaName := str(companion.get("persona_name", "")).strip_edges()
|
|
if not personaName.is_empty():
|
|
return "%s · 当前:%s" % [label, personaName]
|
|
return "%s · 空位" % label
|
|
|
|
func _point_has_companion(point: Dictionary) -> bool:
|
|
var companionVariant: Variant = point.get("companion", null)
|
|
return companionVariant is Dictionary and not (companionVariant as Dictionary).is_empty()
|
|
|
|
func _on_submit_pressed() -> void:
|
|
if _isSubmitting:
|
|
return
|
|
var payload := _build_payload()
|
|
if payload.is_empty():
|
|
return
|
|
|
|
var manager := _get_cafe_companion_manager()
|
|
if manager == null or not manager.has_method("register_employment_agent"):
|
|
_set_status("CafeCompanionManager 未加载", true)
|
|
return
|
|
|
|
_isSubmitting = true
|
|
submitButton.disabled = true
|
|
fetchModelsButton.disabled = true
|
|
_set_status("正在验证代理并提交雇佣登记...", false)
|
|
manager.call("register_employment_agent", payload)
|
|
|
|
func _on_fetch_models_pressed() -> void:
|
|
if _isFetchingModels or _isSubmitting:
|
|
return
|
|
|
|
var protocol := _selected_protocol()
|
|
var baseUrl := baseUrlInput.text.strip_edges()
|
|
var token := tokenInput.text.strip_edges()
|
|
if not (baseUrl.begins_with("http://") or baseUrl.begins_with("https://")):
|
|
_set_status("接口 URL 需要以 http:// 或 https:// 开头", true)
|
|
baseUrlInput.grab_focus()
|
|
return
|
|
if token.is_empty():
|
|
_set_status("请先填写接口 Token", true)
|
|
tokenInput.grab_focus()
|
|
return
|
|
|
|
var manager := _get_cafe_companion_manager()
|
|
if manager == null or not manager.has_method("get_employment_models"):
|
|
_set_status("CafeCompanionManager 未加载", true)
|
|
return
|
|
|
|
_isFetchingModels = true
|
|
fetchModelsButton.disabled = true
|
|
_set_status("正在获取接口可用模型...", false)
|
|
manager.call("get_employment_models", protocol, baseUrl, token)
|
|
|
|
func _on_models_ready(data: Dictionary) -> void:
|
|
if not recruitmentFrame.visible:
|
|
return
|
|
_isFetchingModels = false
|
|
fetchModelsButton.disabled = _isSubmitting
|
|
|
|
var modelsVariant: Variant = data.get("models", [])
|
|
var models: Array = modelsVariant if modelsVariant is Array else []
|
|
_render_models(models)
|
|
|
|
func _build_payload() -> Dictionary:
|
|
var servicePointId := _selected_service_point_id()
|
|
var personaName := personaNameInput.text.strip_edges()
|
|
var employmentMinutes := _selected_employment_minutes()
|
|
var protocol := _selected_protocol()
|
|
var baseUrl := baseUrlInput.text.strip_edges()
|
|
var token := tokenInput.text.strip_edges()
|
|
var model := _selected_model_id()
|
|
var personaPrompt := personaPromptInput.text.strip_edges()
|
|
var welcomeMessage := welcomeMessageInput.text.strip_edges()
|
|
|
|
if servicePointId.is_empty():
|
|
_set_status("请选择陪伴位", true)
|
|
return {}
|
|
if personaName.is_empty():
|
|
_set_status("请填写人设名称", true)
|
|
personaNameInput.grab_focus()
|
|
return {}
|
|
if employmentMinutes <= 0:
|
|
_set_status("请选择打工时间", true)
|
|
return {}
|
|
if not (baseUrl.begins_with("http://") or baseUrl.begins_with("https://")):
|
|
_set_status("接口 URL 需要以 http:// 或 https:// 开头", true)
|
|
baseUrlInput.grab_focus()
|
|
return {}
|
|
if token.is_empty():
|
|
_set_status("请填写接口 Token", true)
|
|
tokenInput.grab_focus()
|
|
return {}
|
|
if model.is_empty():
|
|
_set_status("请填写模型名称", true)
|
|
modelInput.grab_focus()
|
|
return {}
|
|
if personaPrompt.is_empty():
|
|
_set_status("请填写人设指令", true)
|
|
personaPromptInput.grab_focus()
|
|
return {}
|
|
|
|
var payload := {
|
|
"service_point_id": servicePointId,
|
|
"persona_name": personaName,
|
|
"employment_minutes": employmentMinutes,
|
|
"protocol": protocol,
|
|
"base_url": baseUrl,
|
|
"token": token,
|
|
"model": model,
|
|
"persona_prompt": personaPrompt,
|
|
"enabled": enabledCheckBox.button_pressed,
|
|
}
|
|
if not welcomeMessage.is_empty():
|
|
payload["welcome_message"] = welcomeMessage
|
|
return payload
|
|
|
|
func _setup_employment_duration_options() -> void:
|
|
employmentDurationOption.clear()
|
|
var durations := [
|
|
{"label": "30分钟", "minutes": 30},
|
|
{"label": "1小时", "minutes": 60},
|
|
{"label": "2小时", "minutes": 120},
|
|
{"label": "4小时", "minutes": 240},
|
|
{"label": "8小时", "minutes": 480},
|
|
]
|
|
for duration in durations:
|
|
var index := employmentDurationOption.get_item_count()
|
|
employmentDurationOption.add_item(str(duration.get("label", "")))
|
|
employmentDurationOption.set_item_metadata(index, int(duration.get("minutes", 0)))
|
|
employmentDurationOption.select(1)
|
|
|
|
func _selected_employment_minutes() -> int:
|
|
var selectedIndex := employmentDurationOption.selected
|
|
if selectedIndex < 0:
|
|
return 0
|
|
return int(employmentDurationOption.get_item_metadata(selectedIndex))
|
|
|
|
func _selected_service_point_id() -> String:
|
|
var selectedIndex := servicePointOption.selected
|
|
if selectedIndex < 0:
|
|
return ""
|
|
return str(servicePointOption.get_item_metadata(selectedIndex)).strip_edges()
|
|
|
|
func _setup_protocol_options() -> void:
|
|
protocolOption.clear()
|
|
protocolOption.add_item("OpenAI", 0)
|
|
protocolOption.set_item_metadata(0, "openai")
|
|
protocolOption.add_item("Anthropic", 1)
|
|
protocolOption.set_item_metadata(1, "anthropic")
|
|
protocolOption.select(0)
|
|
_apply_protocol_defaults(true)
|
|
|
|
func _on_protocol_selected(_index: int) -> void:
|
|
_apply_protocol_defaults(true)
|
|
_set_status("%s 协议已选择,可填写接口并获取模型" % _selected_protocol_label(), false)
|
|
|
|
func _selected_protocol() -> String:
|
|
var selectedIndex := protocolOption.selected
|
|
if selectedIndex < 0:
|
|
return "openai"
|
|
var protocol := str(protocolOption.get_item_metadata(selectedIndex)).strip_edges()
|
|
return protocol if not protocol.is_empty() else "openai"
|
|
|
|
func _selected_protocol_label() -> String:
|
|
return "Anthropic" if _selected_protocol() == "anthropic" else "OpenAI"
|
|
|
|
func _apply_protocol_defaults(clearModelList: bool) -> void:
|
|
var protocol := _selected_protocol()
|
|
if protocol == "anthropic":
|
|
baseUrlLabel.text = "Anthropic URL"
|
|
baseUrlInput.placeholder_text = "https://api.anthropic.com"
|
|
modelInput.placeholder_text = "claude-sonnet-4-5-20250929"
|
|
if modelInput.text.strip_edges().is_empty() or modelInput.text.strip_edges() == "gpt-4o-mini":
|
|
modelInput.text = "claude-sonnet-4-5-20250929"
|
|
else:
|
|
baseUrlLabel.text = "OpenAI-compatible URL"
|
|
baseUrlInput.placeholder_text = "https://api.example.com/v1"
|
|
modelInput.placeholder_text = "gpt-4o-mini"
|
|
if modelInput.text.strip_edges().is_empty() or modelInput.text.strip_edges().begins_with("claude-"):
|
|
modelInput.text = "gpt-4o-mini"
|
|
if clearModelList:
|
|
modelOption.clear()
|
|
modelOption.visible = false
|
|
|
|
func _render_models(models: Array) -> void:
|
|
modelOption.clear()
|
|
var currentModel := modelInput.text.strip_edges()
|
|
var selectedIndex := -1
|
|
for modelVariant in models:
|
|
var modelId := _model_id_from_variant(modelVariant)
|
|
if modelId.is_empty():
|
|
continue
|
|
var index := modelOption.get_item_count()
|
|
modelOption.add_item(_model_label_from_variant(modelVariant, modelId))
|
|
modelOption.set_item_metadata(index, modelId)
|
|
if modelId == currentModel:
|
|
selectedIndex = index
|
|
|
|
if modelOption.get_item_count() <= 0:
|
|
modelOption.visible = false
|
|
_set_status("接口没有返回可用模型,可继续手动填写模型", true)
|
|
return
|
|
|
|
modelOption.visible = true
|
|
modelOption.select(selectedIndex if selectedIndex >= 0 else 0)
|
|
if currentModel.is_empty() or selectedIndex < 0:
|
|
modelInput.text = str(modelOption.get_item_metadata(modelOption.selected))
|
|
_set_status("已获取 %d 个可用模型" % modelOption.get_item_count(), false)
|
|
|
|
func _on_model_option_selected(index: int) -> void:
|
|
if index < 0:
|
|
return
|
|
modelInput.text = str(modelOption.get_item_metadata(index)).strip_edges()
|
|
|
|
func _selected_model_id() -> String:
|
|
var model := modelInput.text.strip_edges()
|
|
if not model.is_empty():
|
|
return model
|
|
if modelOption.visible and modelOption.selected >= 0:
|
|
return str(modelOption.get_item_metadata(modelOption.selected)).strip_edges()
|
|
return ""
|
|
|
|
func _model_id_from_variant(modelVariant: Variant) -> String:
|
|
if modelVariant is String:
|
|
return str(modelVariant).strip_edges()
|
|
if modelVariant is Dictionary:
|
|
var model: Dictionary = modelVariant
|
|
return str(model.get("id", "")).strip_edges()
|
|
return ""
|
|
|
|
func _model_label_from_variant(modelVariant: Variant, modelId: String) -> String:
|
|
if not (modelVariant is Dictionary):
|
|
return modelId
|
|
var model: Dictionary = modelVariant
|
|
var label := str(model.get("label", modelId)).strip_edges()
|
|
var ownedBy := str(model.get("owned_by", "")).strip_edges()
|
|
if not ownedBy.is_empty():
|
|
return "%s · %s" % [label, ownedBy]
|
|
return label
|
|
|
|
func _on_close_pressed() -> void:
|
|
recruitmentFrame.visible = false
|
|
_isSubmitting = false
|
|
_isFetchingModels = false
|
|
submitButton.disabled = false
|
|
fetchModelsButton.disabled = false
|
|
tokenInput.clear()
|
|
|
|
func is_escape_dismissible() -> bool:
|
|
return recruitmentFrame.visible
|
|
|
|
func get_escape_priority() -> int:
|
|
return 900
|
|
|
|
func request_escape_close() -> void:
|
|
_on_close_pressed()
|
|
|
|
func _set_status(message: String, isError: bool) -> void:
|
|
statusLabel.text = message
|
|
statusLabel.add_theme_color_override("font_color", Color(0.68, 0.22, 0.18, 1) if isError else Color(0.32, 0.42, 0.48, 1))
|
|
|
|
func _get_cafe_companion_manager() -> Node:
|
|
return get_node_or_null("/root/CafeCompanionManager")
|
|
|
|
func _format_service_point_label(servicePointId: String) -> String:
|
|
if servicePointId.begins_with("ServiceIdlePoint"):
|
|
var pointNumber := _parse_point_number(servicePointId)
|
|
if pointNumber > 0:
|
|
return "%s号陪伴位" % _format_chinese_number(pointNumber)
|
|
return servicePointId
|
|
|
|
func _parse_point_number(value: String) -> int:
|
|
var digits := ""
|
|
for index in range(value.length()):
|
|
var character := value.substr(index, 1)
|
|
if character >= "0" and character <= "9":
|
|
digits += character
|
|
return int(digits) if not digits.is_empty() else 0
|
|
|
|
func _format_chinese_number(value: int) -> String:
|
|
const CHINESE_DIGITS: Array[String] = [
|
|
"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"
|
|
]
|
|
if value <= 0:
|
|
return str(value)
|
|
if value < 10:
|
|
return CHINESE_DIGITS[value]
|
|
if value == 10:
|
|
return "十"
|
|
if value < 20:
|
|
return "十%s" % CHINESE_DIGITS[value - 10]
|
|
if value < 100:
|
|
var tens := value / 10
|
|
var ones := value % 10
|
|
return "%s十%s" % [CHINESE_DIGITS[tens], CHINESE_DIGITS[ones]] if ones > 0 else "%s十" % CHINESE_DIGITS[tens]
|
|
return str(value)
|