wow
This commit is contained in:
parent
1ab0263e27
commit
15091ac422
19 changed files with 2328 additions and 263 deletions
File diff suppressed because it is too large
Load diff
303
hosts/machine/ena/home/config/quickshell/OpenAiApiStrategy.qml
Normal file
303
hosts/machine/ena/home/config/quickshell/OpenAiApiStrategy.qml
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import QtQuick
|
||||
|
||||
ApiStrategy {
|
||||
// GPT-5.6 Luna uses the Responses API so reasoning and function tools can
|
||||
// be enabled together. Reasoning summaries are intentionally not rendered
|
||||
// in the chat bubble; only the final output text belongs in the transcript.
|
||||
property string functionName: ""
|
||||
property string functionCallId: ""
|
||||
property string functionItemId: ""
|
||||
property string functionArguments: ""
|
||||
property bool functionCallEmitted: false
|
||||
|
||||
function buildEndpoint(model: AiModel): string {
|
||||
return model.endpoint;
|
||||
}
|
||||
|
||||
function responseTool(tool) {
|
||||
const functionData = tool?.function ?? tool ?? {};
|
||||
let parameters = functionData.parameters;
|
||||
if (!parameters || Object.keys(parameters).length === 0) {
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
"type": "function",
|
||||
"name": functionData.name,
|
||||
"description": functionData.description ?? "",
|
||||
"parameters": parameters,
|
||||
"strict": false,
|
||||
};
|
||||
}
|
||||
|
||||
function previousFunctionCallId(messages, index, name) {
|
||||
for (let i = index - 1; i >= 0; i--) {
|
||||
const call = messages[i].functionCall;
|
||||
if (call && typeof call === "object" && (!name || call.name === name)) {
|
||||
return call.id ?? call.call_id ?? "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildInput(messages) {
|
||||
let input = [];
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
const functionCall = message.functionCall;
|
||||
|
||||
// Responses requires the model's function_call item to be kept in
|
||||
// the input before its corresponding function_call_output item.
|
||||
if (message.role === "assistant" && functionCall && typeof functionCall === "object") {
|
||||
const callId = functionCall.id ?? functionCall.call_id ?? "";
|
||||
const name = message.functionName || functionCall.name || "";
|
||||
if (callId && name) {
|
||||
input.push({
|
||||
"type": "function_call",
|
||||
"id": functionCall.item_id ?? callId,
|
||||
"call_id": callId,
|
||||
"name": name,
|
||||
"arguments": JSON.stringify(functionCall.args ?? {}),
|
||||
"status": "completed",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.functionResponse !== undefined && message.functionName) {
|
||||
const callId = message.functionCall?.id
|
||||
?? message.functionCall?.call_id
|
||||
?? previousFunctionCallId(messages, i, message.functionName);
|
||||
if (callId) {
|
||||
input.push({
|
||||
"type": "function_call_output",
|
||||
"call_id": callId,
|
||||
"output": String(message.functionResponse),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.rawContent && message.rawContent.length > 0) {
|
||||
input.push({
|
||||
"role": message.role === "assistant" ? "assistant" : "user",
|
||||
"content": message.rawContent,
|
||||
});
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
|
||||
let baseData = {
|
||||
"model": model.model,
|
||||
"instructions": systemPrompt,
|
||||
"input": buildInput(messages),
|
||||
"stream": true,
|
||||
"tools": tools.map(responseTool),
|
||||
// Keep Luna's normal reasoning quality. The previous Chat
|
||||
// Completions workaround used reasoning_effort=none, which made
|
||||
// the model appear responsive but substantially worse at answers.
|
||||
"reasoning": {
|
||||
"effort": "medium",
|
||||
},
|
||||
// Luna currently rejects the temperature parameter even though
|
||||
// it is accepted by the general Responses schema.
|
||||
};
|
||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
|
||||
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
|
||||
}
|
||||
|
||||
function appendText(text, message) {
|
||||
if (!text || text.length === 0) return;
|
||||
message.content += text;
|
||||
message.rawContent += text;
|
||||
}
|
||||
|
||||
function appendError(error, message) {
|
||||
const errorMsg = `**Error**: ${error?.message || JSON.stringify(error)}`;
|
||||
appendText(errorMsg, message);
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
function appendOutputItemText(item, message) {
|
||||
if (message.content.length > 0 || !item || !Array.isArray(item.content)) return;
|
||||
for (let i = 0; i < item.content.length; i++) {
|
||||
const content = item.content[i];
|
||||
if ((content?.type === "output_text" || content?.type === "text") && content.text) {
|
||||
appendText(content.text, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function appendResponseOutput(response, message) {
|
||||
if (message.content.length > 0 || !response || !Array.isArray(response.output)) return;
|
||||
for (let i = 0; i < response.output.length; i++) {
|
||||
const item = response.output[i];
|
||||
if (item?.type === "message") appendOutputItemText(item, message);
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsage(response) {
|
||||
const usage = response?.usage;
|
||||
if (!usage) return undefined;
|
||||
return {
|
||||
input: usage.input_tokens ?? -1,
|
||||
output: usage.output_tokens ?? -1,
|
||||
total: usage.total_tokens ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFunctionArguments(message) {
|
||||
let args = {};
|
||||
try {
|
||||
args = JSON.parse(functionArguments || "{}");
|
||||
} catch (e) {
|
||||
appendText(`\n\n[[ Invalid function arguments: ${functionArguments} ]]\n`, message);
|
||||
return null;
|
||||
}
|
||||
|
||||
const name = functionName;
|
||||
const callId = functionCallId || functionItemId;
|
||||
if (!name || !callId || functionCallEmitted) return null;
|
||||
|
||||
const call = {
|
||||
name: name,
|
||||
args: args,
|
||||
id: callId,
|
||||
call_id: callId,
|
||||
item_id: functionItemId,
|
||||
};
|
||||
message.functionName = name;
|
||||
message.functionCall = call;
|
||||
appendText(`\n\n[[ Function: ${name}(${JSON.stringify(args, null, 2)}) ]]\n`, message);
|
||||
functionCallEmitted = true;
|
||||
return { functionCall: call };
|
||||
}
|
||||
|
||||
function parseEventLine(line, message) {
|
||||
let cleanData = line.trim();
|
||||
|
||||
// Responses streams have both `event:` and `data:` SSE lines. The
|
||||
// event name is useful for humans but the JSON type is authoritative.
|
||||
if (cleanData.startsWith("event:")) return {};
|
||||
if (cleanData.startsWith("data:")) cleanData = cleanData.slice(5).trim();
|
||||
if (!cleanData || cleanData.startsWith(":")) return {};
|
||||
if (cleanData === "[DONE]") return { finished: true };
|
||||
|
||||
let dataJson;
|
||||
try {
|
||||
dataJson = JSON.parse(cleanData);
|
||||
} catch (e) {
|
||||
// A partial/non-JSON SSE line must not be rendered as assistant
|
||||
// text. SplitParser normally gives us complete data lines, but
|
||||
// ignoring malformed metadata is safer than corrupting the UI.
|
||||
return {};
|
||||
}
|
||||
|
||||
if (dataJson.error) return appendError(dataJson.error, message);
|
||||
if (dataJson.response?.error) return appendError(dataJson.response.error, message);
|
||||
|
||||
const eventType = dataJson.type || "";
|
||||
if (eventType === "response.output_item.added") {
|
||||
const item = dataJson.item;
|
||||
if (item?.type === "function_call") {
|
||||
functionItemId = item.id ?? "";
|
||||
functionCallId = item.call_id ?? "";
|
||||
functionName = item.name ?? "";
|
||||
functionArguments = item.arguments ?? "";
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (eventType === "response.function_call_arguments.delta") {
|
||||
functionArguments += dataJson.delta ?? "";
|
||||
return {};
|
||||
}
|
||||
|
||||
if (eventType === "response.function_call_arguments.done") {
|
||||
functionArguments = dataJson.arguments ?? functionArguments;
|
||||
return parseFunctionArguments(message) ?? {};
|
||||
}
|
||||
|
||||
if (eventType === "response.output_item.done") {
|
||||
const item = dataJson.item;
|
||||
appendOutputItemText(item, message);
|
||||
if (item?.type === "function_call") {
|
||||
functionItemId = item.id ?? functionItemId;
|
||||
functionCallId = item.call_id ?? functionCallId;
|
||||
functionName = item.name ?? functionName;
|
||||
functionArguments = item.arguments ?? functionArguments;
|
||||
return parseFunctionArguments(message) ?? {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
if (eventType === "response.output_text.delta" || eventType === "response.refusal.delta") {
|
||||
appendText(dataJson.delta, message);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (eventType === "response.output_text.done" || eventType === "response.refusal.done") {
|
||||
// The done event contains the complete text after delta events.
|
||||
// Only use it as a fallback when no delta was received.
|
||||
if (message.content.length === 0) appendText(dataJson.text ?? dataJson.output_text, message);
|
||||
return {};
|
||||
}
|
||||
|
||||
if (eventType === "response.completed") {
|
||||
appendResponseOutput(dataJson.response, message);
|
||||
const tokenUsage = parseUsage(dataJson.response);
|
||||
return tokenUsage ? { tokenUsage: tokenUsage, finished: true } : { finished: true };
|
||||
}
|
||||
|
||||
if (eventType === "response.failed" || eventType === "response.incomplete") {
|
||||
const response = dataJson.response ?? {};
|
||||
return response.error
|
||||
? appendError(response.error, message)
|
||||
: { finished: true };
|
||||
}
|
||||
|
||||
// Some compatible gateways return a complete response object even
|
||||
// when streaming was requested. Handle only its actual text field;
|
||||
// never dump unknown response metadata into the chat bubble.
|
||||
if (dataJson.output_text) appendText(dataJson.output_text, message);
|
||||
appendResponseOutput(dataJson, message);
|
||||
if (dataJson.response?.output_text) appendText(dataJson.response.output_text, message);
|
||||
appendResponseOutput(dataJson.response, message);
|
||||
const tokenUsage = parseUsage(dataJson);
|
||||
return tokenUsage ? { tokenUsage: tokenUsage } : {};
|
||||
}
|
||||
|
||||
function parseResponseLine(line, message) {
|
||||
// SplitParser usually emits one line, but curl/SSE buffering can hand
|
||||
// us several records at once. Parse every physical line so a metadata
|
||||
// event cannot hide the following output_text event.
|
||||
const lines = String(line).split(/\r?\n/);
|
||||
let result = {};
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const parsed = parseEventLine(lines[i], message);
|
||||
if (parsed.functionCall) result.functionCall = parsed.functionCall;
|
||||
if (parsed.tokenUsage) result.tokenUsage = parsed.tokenUsage;
|
||||
if (parsed.finished) result.finished = true;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function onRequestFinished(message) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
functionName = "";
|
||||
functionCallId = "";
|
||||
functionItemId = "";
|
||||
functionArguments = "";
|
||||
functionCallEmitted = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,49 @@
|
|||
home.sessionVariables = {
|
||||
EDITOR = "nano";
|
||||
VISUAL = "nano";
|
||||
GTK_THEME = "Adwaita:dark";
|
||||
};
|
||||
|
||||
dconf.settings = {
|
||||
"org/gnome/desktop/interface" = {
|
||||
color-scheme = "prefer-dark";
|
||||
gtk-theme = "Adwaita-dark";
|
||||
};
|
||||
};
|
||||
|
||||
# Quickshell's Kirigami icons follow the KDE icon theme setting. Keep the
|
||||
# Breeze theme available even though the rest of the session uses Adwaita.
|
||||
xdg.configFile."kdeglobals".text = ''
|
||||
[Icons]
|
||||
Theme=breeze-dark
|
||||
'';
|
||||
|
||||
programs.zed-editor.userSettings.theme.mode = lib.mkForce "dark";
|
||||
programs.ghostty.settings.theme = lib.mkForce "Mizuki Dark";
|
||||
programs.spicetify.colorScheme = lib.mkForce "mocha";
|
||||
|
||||
# Keep Hangul available in every application and make the Hangul key switch
|
||||
# between Korean and the US keyboard layout.
|
||||
xdg.configFile."fcitx5/profile" = {
|
||||
force = true;
|
||||
text = lib.generators.toINI { } {
|
||||
"Groups/0" = {
|
||||
Name = "Default";
|
||||
"Default Layout" = "us";
|
||||
DefaultIM = "hangul";
|
||||
};
|
||||
"Groups/0/Items/0" = {
|
||||
Name = "keyboard-us";
|
||||
Layout = "";
|
||||
};
|
||||
"Groups/0/Items/1" = {
|
||||
Name = "hangul";
|
||||
Layout = "";
|
||||
};
|
||||
GroupOrder = {
|
||||
"0" = "Default";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
xdg.configFile."fontconfig/conf.d/10-hm-fonts.conf".force = true;
|
||||
|
|
@ -31,6 +74,7 @@
|
|||
"${inputs.self}/modules/home/firefox-devedition.nix"
|
||||
"${inputs.self}/modules/home/discord/linux.nix"
|
||||
"${inputs.self}/modules/home/devtool/vscode.nix"
|
||||
"${inputs.self}/modules/home/devtool/vicinae.nix"
|
||||
"${inputs.self}/modules/home/ssh.nix"
|
||||
"${inputs.self}/modules/home/vicinae.nix"
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ with pkgs;
|
|||
# inputs.waterfox.packages.${pkgs.system}.waterfox
|
||||
gnome-network-displays
|
||||
element-desktop
|
||||
# Provides secret-tool, used by Quickshell to load/save API keys.
|
||||
libsecret
|
||||
inputs.imnyang.packages.${stdenv.hostPlatform.system}.figma-linux
|
||||
inputs.hyprmod.packages.${pkgs.system}.default
|
||||
# hoffice
|
||||
]
|
||||
|
|
|
|||
Loading…
Reference in a new issue