File size: 6,359 Bytes
77169b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""OpenAI 兼容的请求/响应模型。"""

from typing import Any

from pydantic import BaseModel, Field

from core.api.conv_parser import strip_session_id_suffix


class OpenAIContentPart(BaseModel):
    type: str
    text: str | None = None
    image_url: dict[str, Any] | str | None = None


class InputAttachment(BaseModel):
    filename: str
    mime_type: str
    data: bytes


class OpenAIMessage(BaseModel):
    role: str = Field(..., description="system | user | assistant | tool")
    content: str | list[OpenAIContentPart] | None = ""
    tool_calls: list[dict[str, Any]] | None = Field(
        default=None, description="assistant 发起的工具调用"
    )
    tool_call_id: str | None = Field(
        default=None, description="tool 消息对应的 call id"
    )

    model_config = {"extra": "allow"}


class OpenAIChatRequest(BaseModel):
    """OpenAI Chat Completions API 兼容请求体。"""

    model: str = Field(default="", description="模型名,可忽略")
    messages: list[OpenAIMessage] = Field(..., description="对话列表")
    stream: bool = Field(default=False, description="是否流式返回")
    tools: list[dict] | None = Field(
        default=None,
        description='工具列表,每项为 {"type":"function","function":{name,description,parameters,strict?}}',
    )
    tool_choice: str | dict | None = Field(
        default=None,
        description='工具选择: "auto"|"required"|"none" 或 {"type":"function","name":"xxx"}',
    )
    parallel_tool_calls: bool | None = Field(
        default=None,
        description="是否允许单次响应中并行多个 tool_call,false 时仅 0 或 1 个",
    )
    resume_session_id: str | None = Field(default=None, exclude=True)
    upstream_model: str | None = Field(default=None, exclude=True)
    attachment_files: list[InputAttachment] = Field(
        default_factory=list,
        exclude=True,
        description="本次实际要发送给站点的附件,由 ChatHandler 根据 full_history 选择来源填充。",
    )
    # 仅供内部调度使用:最后一条 user 消息里的附件 & 所有 user 消息里的附件
    attachment_files_last_user: list[InputAttachment] = Field(
        default_factory=list, exclude=True
    )
    attachment_files_all_users: list[InputAttachment] = Field(
        default_factory=list, exclude=True
    )


def _norm_content(c: str | list[OpenAIContentPart] | None) -> str:
    """将 content 转为单段字符串。仅支持官方格式:字符串或 type=text 的 content part(取 text 字段)。"""
    if c is None:
        return ""
    if isinstance(c, str):
        return strip_session_id_suffix(c)
    if not isinstance(c, list):
        return ""
    return strip_session_id_suffix(
        " ".join(
            p.text or ""
            for p in c
            if isinstance(p, OpenAIContentPart) and p.type == "text" and p.text
        )
    )


REACT_STRICT_SUFFIX = (
    "(严格 ReAct 执行模式;禁止输出「无法执行工具所以直接给方案」等解释或替代内容)"
)


def extract_user_content(
    messages: list[OpenAIMessage],
    *,
    has_tools: bool = False,
    react_prompt_prefix: str = "",
    full_history: bool = False,
) -> str:
    """
    从 messages 中提取对话,拼成发给模型的 prompt。
    网页/会话侧已有完整历史,只取尾部:最后一条为 user 时,从后向前找到最后一个 assistant(不包含),
    取该 assistant 之后到末尾;最后一条为 tool 时,从后向前找到最后一个 user(不包含),取该 user 之后到末尾。
    支持 user、assistant、tool 角色;assistant 的 tool_calls 与 tool 结果会拼回。
    ReAct 模式:完整 ReAct Prompt 仅第一次对话传入(按完整 messages 判断 is_first_turn);后续只传尾部内容。
    """
    if not messages:
        return ""

    parts: list[str] = []

    # 重建会话时会把完整历史重新回放给站点,因此 tools 指令也需要重新注入。
    is_first_turn = not any(m.role in ("assistant", "tool") for m in messages)
    if has_tools and react_prompt_prefix and (full_history or is_first_turn):
        parts.append(react_prompt_prefix)

    if full_history:
        tail = messages
    else:
        last = messages[-1]
        if last.role == "user":
            i = len(messages) - 1
            while i >= 0 and messages[i].role != "assistant":
                i -= 1
            tail = messages[i + 1 :]
        elif last.role == "tool":
            i = len(messages) - 1
            while i >= 0 and messages[i].role != "user":
                i -= 1
            tail = messages[i + 1 :]
        else:
            tail = messages[-2:]

    for m in tail:
        if m.role == "system":
            txt = _norm_content(m.content)
            if txt:
                parts.append(f"System:{txt}")
        elif m.role == "user":
            txt = _norm_content(m.content)
            if txt:
                if has_tools:
                    parts.append(f"**User**: {txt} {REACT_STRICT_SUFFIX}")
                else:
                    parts.append(f"User:{txt}")
        elif m.role == "assistant":
            tool_calls_list = list(m.tool_calls or [])
            if tool_calls_list:
                for tc in tool_calls_list:
                    fn = tc.get("function") or {}
                    call_id = tc.get("id", "")
                    name = fn.get("name", "")
                    args = fn.get("arguments", "{}")
                    parts.append(
                        f"**Assistant**:\n\n```\nAction: {name}\nAction Input: {args}\nCall ID: {call_id}\n```"
                    )
            else:
                txt = _norm_content(m.content)
                if txt:
                    if has_tools:
                        parts.append(f"**Assistant**:\n\n{txt}")
                    else:
                        parts.append(f"Assistant:{txt}")
        elif m.role == "tool":
            txt = _norm_content(m.content)
            call_id = m.tool_call_id or ""
            parts.append(
                f"**Observation(Call ID: {call_id})**: {txt}\n\n请根据以上观察结果继续。如需调用工具,输出 Thought / Action / Action Input;若任务已完成,输出 Final Answer。"
            )
    return "\n".join(parts)